xrefkit 0.3.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.
- xrefkit/__init__.py +5 -0
- xrefkit/__main__.py +5 -0
- xrefkit/catalog_cli.py +57 -0
- xrefkit/cli.py +71 -0
- xrefkit/contracts.py +297 -0
- xrefkit/ctx.py +160 -0
- xrefkit/dashboard.py +1220 -0
- xrefkit/discovery.py +85 -0
- xrefkit/gate.py +428 -0
- xrefkit/goalstate.py +555 -0
- xrefkit/hashing.py +18 -0
- xrefkit/import_skill.py +469 -0
- xrefkit/instance.py +133 -0
- xrefkit/loaders.py +77 -0
- xrefkit/mcp/__init__.py +26 -0
- xrefkit/mcp/audit.py +168 -0
- xrefkit/mcp/bootstrap.py +337 -0
- xrefkit/mcp/catalog.py +2638 -0
- xrefkit/mcp/cli.py +173 -0
- xrefkit/mcp/client_cache.py +356 -0
- xrefkit/mcp/context_registry.py +277 -0
- xrefkit/mcp/contracts.py +243 -0
- xrefkit/mcp/dist.py +234 -0
- xrefkit/mcp/ownership.py +246 -0
- xrefkit/mcp/repository.py +217 -0
- xrefkit/mcp/schemas.py +349 -0
- xrefkit/mcp/server.py +773 -0
- xrefkit/mcp/startup_contract_pack.py +154 -0
- xrefkit/mcp_tools.py +47 -0
- xrefkit/models/__init__.py +41 -0
- xrefkit/models/common.py +185 -0
- xrefkit/models/effective_bundle.py +131 -0
- xrefkit/models/local_manifest.py +217 -0
- xrefkit/models/package_manifest.py +126 -0
- xrefkit/models/run_log.py +276 -0
- xrefkit/models/server_config.py +160 -0
- xrefkit/models/skill_definition.py +131 -0
- xrefkit/operations_cli.py +670 -0
- xrefkit/ownership.py +276 -0
- xrefkit/packmeta.py +289 -0
- xrefkit/registry.py +334 -0
- xrefkit/resolver.py +252 -0
- xrefkit/resource_provider.py +187 -0
- xrefkit/resources/base/contracts.json +178 -0
- xrefkit/resources/base/current.json +6 -0
- xrefkit/resources/base/generations/7a682a5272907354/contracts.json +178 -0
- xrefkit/resources/base/generations/7a682a5272907354/model_body.md +22 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/contracts.json +178 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/model_body.md +22 -0
- xrefkit/resources/base/model_body.md +22 -0
- xrefkit/runlog.py +45 -0
- xrefkit/skillmeta.py +1034 -0
- xrefkit/skillrun.py +2381 -0
- xrefkit/structure_catalog.py +199 -0
- xrefkit/tools/__init__.py +119 -0
- xrefkit/tools/__main__.py +4 -0
- xrefkit/v2_cli.py +130 -0
- xrefkit/workspace.py +117 -0
- xrefkit/xref.py +1048 -0
- xrefkit-0.3.0.dist-info/METADATA +203 -0
- xrefkit-0.3.0.dist-info/RECORD +65 -0
- xrefkit-0.3.0.dist-info/WHEEL +5 -0
- xrefkit-0.3.0.dist-info/entry_points.txt +2 -0
- xrefkit-0.3.0.dist-info/licenses/LICENSE +21 -0
- xrefkit-0.3.0.dist-info/top_level.txt +1 -0
xrefkit/mcp/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Read-only catalog projection for XRefKit repositories."""
|
|
2
|
+
|
|
3
|
+
from .catalog import XRefCatalog
|
|
4
|
+
from .client_cache import DocumentCacheProtocolError, XidDocumentCache
|
|
5
|
+
from .context_registry import PromptContextAssembler, SessionXidContextRegistry
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.5"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv: list[str] | None = None) -> int:
|
|
11
|
+
from .server import main as server_main
|
|
12
|
+
|
|
13
|
+
args = list(argv or [])
|
|
14
|
+
if args and args[0] == "serve":
|
|
15
|
+
args = args[1:]
|
|
16
|
+
return server_main(args)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DocumentCacheProtocolError",
|
|
20
|
+
"PromptContextAssembler",
|
|
21
|
+
"SessionXidContextRegistry",
|
|
22
|
+
"XRefCatalog",
|
|
23
|
+
"XidDocumentCache",
|
|
24
|
+
"main",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
xrefkit/mcp/audit.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
import uuid
|
|
7
|
+
import weakref
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
if os.name == "nt":
|
|
15
|
+
import msvcrt
|
|
16
|
+
else:
|
|
17
|
+
import fcntl
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
AUDIT_SCHEMA = "xrefkit.mcp_audit/v1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _write_all(fd: int, data: bytes, *, write=None) -> None:
|
|
24
|
+
writer = write or os.write
|
|
25
|
+
offset = 0
|
|
26
|
+
while offset < len(data):
|
|
27
|
+
written = writer(fd, data[offset:])
|
|
28
|
+
if written <= 0:
|
|
29
|
+
raise OSError("audit log write did not make progress")
|
|
30
|
+
offset += written
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@contextmanager
|
|
34
|
+
def _process_lock(path: Path):
|
|
35
|
+
lock_path = path.with_suffix(path.suffix + ".lock")
|
|
36
|
+
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
37
|
+
locked = False
|
|
38
|
+
try:
|
|
39
|
+
if os.name == "nt":
|
|
40
|
+
if os.fstat(fd).st_size == 0:
|
|
41
|
+
_write_all(fd, b"\0")
|
|
42
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
43
|
+
msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
|
|
44
|
+
else:
|
|
45
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
46
|
+
locked = True
|
|
47
|
+
yield
|
|
48
|
+
finally:
|
|
49
|
+
if locked:
|
|
50
|
+
if os.name == "nt":
|
|
51
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
52
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
53
|
+
else:
|
|
54
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
55
|
+
os.close(fd)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class SessionRunBinding:
|
|
60
|
+
run_id: str
|
|
61
|
+
mcp_session_id: str
|
|
62
|
+
repository_fingerprint: str
|
|
63
|
+
skill_id: str
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict[str, str]:
|
|
66
|
+
return {
|
|
67
|
+
"run_id": self.run_id,
|
|
68
|
+
"mcp_session_id": self.mcp_session_id,
|
|
69
|
+
"repository_fingerprint": self.repository_fingerprint,
|
|
70
|
+
"skill_id": self.skill_id,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SessionRunRegistry:
|
|
75
|
+
def __init__(self) -> None:
|
|
76
|
+
self._bindings: "weakref.WeakKeyDictionary[Any, SessionRunBinding]" = weakref.WeakKeyDictionary()
|
|
77
|
+
self._lock = threading.RLock()
|
|
78
|
+
|
|
79
|
+
def bind(
|
|
80
|
+
self,
|
|
81
|
+
session: Any,
|
|
82
|
+
*,
|
|
83
|
+
run_id: str,
|
|
84
|
+
repository_fingerprint: str,
|
|
85
|
+
skill_id: str,
|
|
86
|
+
) -> SessionRunBinding:
|
|
87
|
+
if session is None:
|
|
88
|
+
raise ValueError("MCP session is required for Skill Run binding")
|
|
89
|
+
try:
|
|
90
|
+
normalized_run_id = str(uuid.UUID(str(run_id)))
|
|
91
|
+
except ValueError as exc:
|
|
92
|
+
raise ValueError(f"run_id must be a UUID: {run_id}") from exc
|
|
93
|
+
normalized_skill_id = str(skill_id).strip()
|
|
94
|
+
normalized_fingerprint = str(repository_fingerprint).strip()
|
|
95
|
+
if not normalized_skill_id:
|
|
96
|
+
raise ValueError("skill_id is required")
|
|
97
|
+
if not normalized_fingerprint:
|
|
98
|
+
raise ValueError("repository_fingerprint is required")
|
|
99
|
+
with self._lock:
|
|
100
|
+
current = self._bindings.get(session)
|
|
101
|
+
if current is not None:
|
|
102
|
+
requested = (normalized_run_id, normalized_fingerprint, normalized_skill_id)
|
|
103
|
+
existing = (current.run_id, current.repository_fingerprint, current.skill_id)
|
|
104
|
+
if requested != existing:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
"MCP session is already bound to a different Skill Run; end the current run before binding another"
|
|
107
|
+
)
|
|
108
|
+
return current
|
|
109
|
+
mcp_session_id = current.mcp_session_id if current else str(uuid.uuid4())
|
|
110
|
+
binding = SessionRunBinding(
|
|
111
|
+
run_id=normalized_run_id,
|
|
112
|
+
mcp_session_id=mcp_session_id,
|
|
113
|
+
repository_fingerprint=normalized_fingerprint,
|
|
114
|
+
skill_id=normalized_skill_id,
|
|
115
|
+
)
|
|
116
|
+
self._bindings[session] = binding
|
|
117
|
+
return binding
|
|
118
|
+
|
|
119
|
+
def end(self, session: Any, *, run_id: str) -> SessionRunBinding:
|
|
120
|
+
if session is None:
|
|
121
|
+
raise ValueError("MCP session is required for Skill Run end")
|
|
122
|
+
try:
|
|
123
|
+
normalized_run_id = str(uuid.UUID(str(run_id)))
|
|
124
|
+
except ValueError as exc:
|
|
125
|
+
raise ValueError(f"run_id must be a UUID: {run_id}") from exc
|
|
126
|
+
with self._lock:
|
|
127
|
+
current = self._bindings.get(session)
|
|
128
|
+
if current is None:
|
|
129
|
+
raise ValueError("MCP session has no active Skill Run binding")
|
|
130
|
+
if current.run_id != normalized_run_id:
|
|
131
|
+
raise ValueError("run_id does not match the active MCP Skill Run binding")
|
|
132
|
+
del self._bindings[session]
|
|
133
|
+
return current
|
|
134
|
+
|
|
135
|
+
def current(self, session: Any) -> SessionRunBinding | None:
|
|
136
|
+
if session is None:
|
|
137
|
+
return None
|
|
138
|
+
with self._lock:
|
|
139
|
+
return self._bindings.get(session)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class McpAuditLog:
|
|
143
|
+
def __init__(self, path: Path) -> None:
|
|
144
|
+
self.path = path.resolve()
|
|
145
|
+
self._lock = threading.Lock()
|
|
146
|
+
|
|
147
|
+
def append(self, event_type: str, *, binding: SessionRunBinding, **fields: object) -> dict[str, object]:
|
|
148
|
+
payload: dict[str, object] = {
|
|
149
|
+
**fields,
|
|
150
|
+
"schema": AUDIT_SCHEMA,
|
|
151
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
152
|
+
"event_type": str(event_type),
|
|
153
|
+
**binding.to_dict(),
|
|
154
|
+
}
|
|
155
|
+
line = (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
|
|
156
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
flags = os.O_APPEND | os.O_CREAT | os.O_WRONLY
|
|
158
|
+
if hasattr(os, "O_BINARY"):
|
|
159
|
+
flags |= os.O_BINARY
|
|
160
|
+
with self._lock:
|
|
161
|
+
with _process_lock(self.path):
|
|
162
|
+
fd = os.open(self.path, flags, 0o600)
|
|
163
|
+
try:
|
|
164
|
+
_write_all(fd, line)
|
|
165
|
+
os.fsync(fd)
|
|
166
|
+
finally:
|
|
167
|
+
os.close(fd)
|
|
168
|
+
return payload
|
xrefkit/mcp/bootstrap.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Stdlib-only bootstrap client for XRefKit MCP artifact distribution.
|
|
2
|
+
|
|
3
|
+
This script is distributed by the XRefKit MCP server itself at
|
|
4
|
+
``GET <base-url>/dist/bootstrap.py``. It has no dependency on pip, PyPI, or
|
|
5
|
+
the ``mcp`` package: everything it needs is in the Python standard library,
|
|
6
|
+
so it works on networks where only the XRefKit MCP endpoint is reachable.
|
|
7
|
+
|
|
8
|
+
It downloads distributable artifacts (the xrefkit Skill-execution runtime and the
|
|
9
|
+
client-side tools) over plain HTTP GET from the server's ``/dist`` routes,
|
|
10
|
+
verifies their sha256 against the ``/dist/index.json`` manifest, and either
|
|
11
|
+
materializes the files into the target repository (default) or installs the
|
|
12
|
+
packages with ``pip --no-index``.
|
|
13
|
+
|
|
14
|
+
Package bytes never pass through an MCP tool result, so they never enter the
|
|
15
|
+
model context of an AI client driving this script.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
|
|
19
|
+
python bootstrap.py --base-url https://host:8443 [--target .]
|
|
20
|
+
[--mode materialize|pip] [--ca-file corp-ca.pem]
|
|
21
|
+
[--startup-context startup.json]
|
|
22
|
+
|
|
23
|
+
``--startup-context`` additionally performs a minimal MCP handshake
|
|
24
|
+
(JSON-RPC over streamable HTTP, stdlib only) and saves the
|
|
25
|
+
``get_startup_context`` result, honoring the "startup context first"
|
|
26
|
+
ordering before artifacts are used.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import hashlib
|
|
32
|
+
import io
|
|
33
|
+
import json
|
|
34
|
+
import ssl
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import urllib.request
|
|
38
|
+
import zipfile
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
DIST_STATE_RELATIVE_PATH = ".xrefkit/dist-state.json"
|
|
43
|
+
MATERIALIZED_PACKAGE_KINDS = {"pip_package"}
|
|
44
|
+
# Package-metadata files at the archive root that describe the package
|
|
45
|
+
# itself and must not be materialized into the target repository root.
|
|
46
|
+
PACKAGE_METADATA_NAMES = {"pyproject.toml", "README.md"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BootstrapError(RuntimeError):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _ssl_context(ca_file: str | None) -> ssl.SSLContext:
|
|
54
|
+
if ca_file:
|
|
55
|
+
return ssl.create_default_context(cafile=ca_file)
|
|
56
|
+
return ssl.create_default_context()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def http_get(url: str, ca_file: str | None = None, headers: dict[str, str] | None = None) -> bytes:
|
|
60
|
+
request = urllib.request.Request(url, headers=headers or {})
|
|
61
|
+
with urllib.request.urlopen(request, context=_ssl_context(ca_file)) as response:
|
|
62
|
+
return response.read()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def http_post_json(
|
|
66
|
+
url: str,
|
|
67
|
+
payload: dict,
|
|
68
|
+
ca_file: str | None = None,
|
|
69
|
+
headers: dict[str, str] | None = None,
|
|
70
|
+
) -> tuple[bytes, dict[str, str]]:
|
|
71
|
+
request_headers = {
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
"Accept": "application/json, text/event-stream",
|
|
74
|
+
}
|
|
75
|
+
request_headers.update(headers or {})
|
|
76
|
+
request = urllib.request.Request(
|
|
77
|
+
url,
|
|
78
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
79
|
+
headers=request_headers,
|
|
80
|
+
method="POST",
|
|
81
|
+
)
|
|
82
|
+
with urllib.request.urlopen(request, context=_ssl_context(ca_file)) as response:
|
|
83
|
+
return response.read(), {key.lower(): value for key, value in response.headers.items()}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_jsonrpc_response(raw: bytes) -> dict | None:
|
|
87
|
+
"""Parse a streamable-HTTP MCP response body (SSE or plain JSON)."""
|
|
88
|
+
text = raw.decode("utf-8", errors="replace").strip()
|
|
89
|
+
if not text:
|
|
90
|
+
return None
|
|
91
|
+
if not text.startswith("data:") and "\ndata:" not in text and "\rdata:" not in text:
|
|
92
|
+
return json.loads(text)
|
|
93
|
+
result: dict | None = None
|
|
94
|
+
for line in text.splitlines():
|
|
95
|
+
if not line.startswith("data:"):
|
|
96
|
+
continue
|
|
97
|
+
candidate = line[len("data:"):].strip()
|
|
98
|
+
if not candidate:
|
|
99
|
+
continue
|
|
100
|
+
message = json.loads(candidate)
|
|
101
|
+
if isinstance(message, dict) and ("result" in message or "error" in message):
|
|
102
|
+
result = message
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def sha256_hex(content: bytes) -> str:
|
|
107
|
+
return hashlib.sha256(content).hexdigest()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def verify_sha256(content: bytes, expected: str, name: str) -> None:
|
|
111
|
+
actual = sha256_hex(content)
|
|
112
|
+
if actual != expected:
|
|
113
|
+
raise BootstrapError(
|
|
114
|
+
f"sha256 mismatch for {name}: expected {expected}, got {actual}. "
|
|
115
|
+
"Do not use this artifact; the download is corrupt or the server "
|
|
116
|
+
"content changed between the manifest read and the download."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def fetch_index(base_url: str, ca_file: str | None) -> dict:
|
|
121
|
+
raw = http_get(f"{base_url.rstrip('/')}/dist/index.json", ca_file)
|
|
122
|
+
index = json.loads(raw.decode("utf-8"))
|
|
123
|
+
if not isinstance(index, dict) or "artifacts" not in index:
|
|
124
|
+
raise BootstrapError("unexpected /dist/index.json shape: missing 'artifacts'")
|
|
125
|
+
return index
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _stripped_member_path(name: str) -> Path | None:
|
|
129
|
+
"""Strip the top-level package directory from an archive member name.
|
|
130
|
+
|
|
131
|
+
Returns None for members that must not be materialized (package
|
|
132
|
+
metadata, unsafe paths, directories).
|
|
133
|
+
"""
|
|
134
|
+
normalized = name.replace("\\", "/")
|
|
135
|
+
if normalized.endswith("/"):
|
|
136
|
+
return None
|
|
137
|
+
parts = [part for part in normalized.split("/") if part]
|
|
138
|
+
if len(parts) < 2:
|
|
139
|
+
return None
|
|
140
|
+
stripped = parts[1:]
|
|
141
|
+
if any(part in ("..", "") or ":" in part for part in stripped):
|
|
142
|
+
return None
|
|
143
|
+
if len(stripped) == 1 and stripped[0] in PACKAGE_METADATA_NAMES:
|
|
144
|
+
return None
|
|
145
|
+
return Path(*stripped)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def materialize_package_zip(content: bytes, target_root: Path) -> list[str]:
|
|
149
|
+
"""Extract a package zip into the target repo, stripping the package root.
|
|
150
|
+
|
|
151
|
+
The archives are laid out as ``<package_root>/xrefkit/...`` or
|
|
152
|
+
``<package_root>/tools/...``; the returned relative paths are what was
|
|
153
|
+
written under ``target_root``.
|
|
154
|
+
"""
|
|
155
|
+
written: list[str] = []
|
|
156
|
+
resolved_root = target_root.resolve()
|
|
157
|
+
with zipfile.ZipFile(io.BytesIO(content)) as archive:
|
|
158
|
+
for member in archive.infolist():
|
|
159
|
+
relative = _stripped_member_path(member.filename)
|
|
160
|
+
if relative is None:
|
|
161
|
+
continue
|
|
162
|
+
destination = (resolved_root / relative).resolve()
|
|
163
|
+
if resolved_root not in destination.parents and destination != resolved_root:
|
|
164
|
+
raise BootstrapError(f"unsafe archive member path: {member.filename}")
|
|
165
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
destination.write_bytes(archive.read(member))
|
|
167
|
+
written.append(relative.as_posix())
|
|
168
|
+
return written
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def pip_install(paths: list[Path]) -> None:
|
|
172
|
+
if not paths:
|
|
173
|
+
return
|
|
174
|
+
command = [
|
|
175
|
+
sys.executable,
|
|
176
|
+
"-m",
|
|
177
|
+
"pip",
|
|
178
|
+
"install",
|
|
179
|
+
"--no-index",
|
|
180
|
+
"--no-build-isolation",
|
|
181
|
+
*[str(path) for path in paths],
|
|
182
|
+
]
|
|
183
|
+
print(f"+ {' '.join(command)}")
|
|
184
|
+
completed = subprocess.run(command, check=False)
|
|
185
|
+
if completed.returncode != 0:
|
|
186
|
+
raise BootstrapError(f"pip install failed with exit code {completed.returncode}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def save_dist_state(target_root: Path, entries: list[dict]) -> Path:
|
|
190
|
+
state_path = target_root / DIST_STATE_RELATIVE_PATH
|
|
191
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
state_path.write_text(
|
|
193
|
+
json.dumps({"artifacts": entries}, indent=2, sort_keys=True) + "\n",
|
|
194
|
+
encoding="utf-8",
|
|
195
|
+
)
|
|
196
|
+
return state_path
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def fetch_startup_context(base_url: str, mcp_path: str, ca_file: str | None) -> dict:
|
|
200
|
+
"""Minimal MCP streamable-HTTP handshake using only the stdlib."""
|
|
201
|
+
endpoint = f"{base_url.rstrip('/')}{mcp_path}"
|
|
202
|
+
raw, headers = http_post_json(
|
|
203
|
+
endpoint,
|
|
204
|
+
{
|
|
205
|
+
"jsonrpc": "2.0",
|
|
206
|
+
"id": 1,
|
|
207
|
+
"method": "initialize",
|
|
208
|
+
"params": {
|
|
209
|
+
"protocolVersion": "2025-03-26",
|
|
210
|
+
"capabilities": {},
|
|
211
|
+
"clientInfo": {"name": "xrefkit-bootstrap", "version": "1"},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
ca_file,
|
|
215
|
+
)
|
|
216
|
+
initialize = parse_jsonrpc_response(raw)
|
|
217
|
+
if not initialize or "result" not in initialize:
|
|
218
|
+
raise BootstrapError(f"MCP initialize failed: {initialize!r}")
|
|
219
|
+
session_id = headers.get("mcp-session-id")
|
|
220
|
+
session_headers = {"mcp-session-id": session_id} if session_id else {}
|
|
221
|
+
|
|
222
|
+
http_post_json(
|
|
223
|
+
endpoint,
|
|
224
|
+
{"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}},
|
|
225
|
+
ca_file,
|
|
226
|
+
session_headers,
|
|
227
|
+
)
|
|
228
|
+
raw, _headers = http_post_json(
|
|
229
|
+
endpoint,
|
|
230
|
+
{
|
|
231
|
+
"jsonrpc": "2.0",
|
|
232
|
+
"id": 2,
|
|
233
|
+
"method": "tools/call",
|
|
234
|
+
"params": {"name": "get_startup_context", "arguments": {}},
|
|
235
|
+
},
|
|
236
|
+
ca_file,
|
|
237
|
+
session_headers,
|
|
238
|
+
)
|
|
239
|
+
response = parse_jsonrpc_response(raw)
|
|
240
|
+
if not response or "result" not in response:
|
|
241
|
+
raise BootstrapError(f"get_startup_context failed: {response!r}")
|
|
242
|
+
return response["result"]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def run(argv: list[str] | None = None) -> int:
|
|
246
|
+
parser = argparse.ArgumentParser(prog="xrefkit-bootstrap", description=__doc__)
|
|
247
|
+
parser.add_argument("--base-url", required=True, help="Server base URL, e.g. https://host:8443")
|
|
248
|
+
parser.add_argument("--target", default=".", help="Client-side target repository root")
|
|
249
|
+
parser.add_argument(
|
|
250
|
+
"--mode",
|
|
251
|
+
choices=["materialize", "pip"],
|
|
252
|
+
default="materialize",
|
|
253
|
+
help="materialize: write xrefkit/ and tools/ files into the target repo; "
|
|
254
|
+
"pip: download packages and pip install them offline",
|
|
255
|
+
)
|
|
256
|
+
parser.add_argument("--ca-file", help="PEM CA bundle for a private/corporate CA")
|
|
257
|
+
parser.add_argument(
|
|
258
|
+
"--mcp-path",
|
|
259
|
+
default="/mcp",
|
|
260
|
+
help="Streamable HTTP MCP path on the same server",
|
|
261
|
+
)
|
|
262
|
+
parser.add_argument(
|
|
263
|
+
"--startup-context",
|
|
264
|
+
help="Also call get_startup_context over MCP JSON-RPC and save the result to this file",
|
|
265
|
+
)
|
|
266
|
+
args = parser.parse_args(argv)
|
|
267
|
+
|
|
268
|
+
base_url = args.base_url.rstrip("/")
|
|
269
|
+
target_root = Path(args.target)
|
|
270
|
+
target_root.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
|
|
272
|
+
if args.startup_context:
|
|
273
|
+
startup = fetch_startup_context(base_url, args.mcp_path, args.ca_file)
|
|
274
|
+
Path(args.startup_context).write_text(
|
|
275
|
+
json.dumps(startup, ensure_ascii=False, indent=2) + "\n",
|
|
276
|
+
encoding="utf-8",
|
|
277
|
+
)
|
|
278
|
+
print(f"saved startup context to {args.startup_context}")
|
|
279
|
+
|
|
280
|
+
index = fetch_index(base_url, args.ca_file)
|
|
281
|
+
state_entries: list[dict] = []
|
|
282
|
+
downloads: list[tuple[dict, bytes]] = []
|
|
283
|
+
for artifact in index["artifacts"]:
|
|
284
|
+
if artifact["kind"] not in MATERIALIZED_PACKAGE_KINDS | {"wheel"}:
|
|
285
|
+
continue
|
|
286
|
+
content = http_get(artifact["url"], args.ca_file)
|
|
287
|
+
verify_sha256(content, artifact["sha256"], artifact["filename"])
|
|
288
|
+
downloads.append((artifact, content))
|
|
289
|
+
print(f"downloaded {artifact['filename']} ({len(content)} bytes, sha256 ok)")
|
|
290
|
+
|
|
291
|
+
if args.mode == "materialize":
|
|
292
|
+
for artifact, content in downloads:
|
|
293
|
+
if artifact["kind"] != "pip_package":
|
|
294
|
+
print(f"skipping {artifact['filename']}: wheels are pip-mode only")
|
|
295
|
+
continue
|
|
296
|
+
written = materialize_package_zip(content, target_root)
|
|
297
|
+
print(f"materialized {len(written)} files from {artifact['filename']}")
|
|
298
|
+
state_entries.append(
|
|
299
|
+
{
|
|
300
|
+
"filename": artifact["filename"],
|
|
301
|
+
"package_id": artifact.get("package_id"),
|
|
302
|
+
"version": artifact.get("version"),
|
|
303
|
+
"sha256": artifact["sha256"],
|
|
304
|
+
"mode": "materialize",
|
|
305
|
+
"files": written,
|
|
306
|
+
}
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
download_dir = target_root / ".xrefkit" / "dist"
|
|
310
|
+
download_dir.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
saved_paths: list[Path] = []
|
|
312
|
+
for artifact, content in downloads:
|
|
313
|
+
path = download_dir / artifact["filename"]
|
|
314
|
+
path.write_bytes(content)
|
|
315
|
+
saved_paths.append(path)
|
|
316
|
+
state_entries.append(
|
|
317
|
+
{
|
|
318
|
+
"filename": artifact["filename"],
|
|
319
|
+
"package_id": artifact.get("package_id"),
|
|
320
|
+
"version": artifact.get("version"),
|
|
321
|
+
"sha256": artifact["sha256"],
|
|
322
|
+
"mode": "pip",
|
|
323
|
+
}
|
|
324
|
+
)
|
|
325
|
+
pip_install(saved_paths)
|
|
326
|
+
|
|
327
|
+
state_path = save_dist_state(target_root, state_entries)
|
|
328
|
+
print(f"recorded dist state at {state_path}")
|
|
329
|
+
return 0
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
if __name__ == "__main__":
|
|
333
|
+
try:
|
|
334
|
+
raise SystemExit(run())
|
|
335
|
+
except BootstrapError as error:
|
|
336
|
+
print(f"bootstrap failed: {error}", file=sys.stderr)
|
|
337
|
+
raise SystemExit(1)
|