sie-mcp 0.8.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.
- sie_mcp/__init__.py +0 -0
- sie_mcp/app.py +37 -0
- sie_mcp/auth.py +173 -0
- sie_mcp/chunking.py +70 -0
- sie_mcp/cli.py +173 -0
- sie_mcp/config.py +273 -0
- sie_mcp/describe.py +179 -0
- sie_mcp/documents.py +33 -0
- sie_mcp/jobs.py +79 -0
- sie_mcp/oauth.py +357 -0
- sie_mcp/ocr_routing.py +344 -0
- sie_mcp/offload.py +422 -0
- sie_mcp/plugin_pack.py +208 -0
- sie_mcp/qa.py +363 -0
- sie_mcp/savings.py +93 -0
- sie_mcp/server.py +457 -0
- sie_mcp/skill_zip.py +108 -0
- sie_mcp/structured.py +394 -0
- sie_mcp-0.8.0.dist-info/METADATA +14 -0
- sie_mcp-0.8.0.dist-info/RECORD +22 -0
- sie_mcp-0.8.0.dist-info/WHEEL +4 -0
- sie_mcp-0.8.0.dist-info/entry_points.txt +2 -0
sie_mcp/__init__.py
ADDED
|
File without changes
|
sie_mcp/app.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""ASGI app for the SIE MCP edge: MCP streamable-HTTP transport + auth + health."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from starlette.applications import Starlette
|
|
6
|
+
from starlette.requests import Request
|
|
7
|
+
from starlette.responses import JSONResponse
|
|
8
|
+
from starlette.routing import Route
|
|
9
|
+
|
|
10
|
+
from sie_mcp.auth import ConnectorSecretAuthMiddleware
|
|
11
|
+
from sie_mcp.config import MCPConfig
|
|
12
|
+
from sie_mcp.oauth import build_oauth_routes
|
|
13
|
+
from sie_mcp.server import build_server
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _healthz(_request: Request) -> JSONResponse:
|
|
19
|
+
return JSONResponse({"status": "ok"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_app() -> Starlette:
|
|
23
|
+
"""Construct the ASGI app (uvicorn factory target)."""
|
|
24
|
+
config = MCPConfig.from_env()
|
|
25
|
+
app = build_server(config).streamable_http_app()
|
|
26
|
+
app.router.routes.append(Route("/healthz", _healthz, methods=["GET"]))
|
|
27
|
+
if config.oauth_enabled:
|
|
28
|
+
if not config.public_base_url:
|
|
29
|
+
logger.warning(
|
|
30
|
+
"SIE_MCP_PUBLIC_URL is unset: OAuth metadata is served only for loopback or SIE_MCP_ALLOWED_HOSTS "
|
|
31
|
+
"Host headers. Pin SIE_MCP_PUBLIC_URL to the public https origin for any exposed deployment."
|
|
32
|
+
)
|
|
33
|
+
# The OAuth bridge lets claude.ai connectors authenticate via the connector
|
|
34
|
+
# secret; the gate below exempts these bootstrap endpoints.
|
|
35
|
+
app.router.routes.extend(build_oauth_routes(config))
|
|
36
|
+
app.add_middleware(ConnectorSecretAuthMiddleware, config=config)
|
|
37
|
+
return app
|
sie_mcp/auth.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Connector-secret authentication at the MCP edge.
|
|
2
|
+
|
|
3
|
+
A per-user connector secret (Bearer) is validated here and mapped to a stable
|
|
4
|
+
user identity, so per-user metering can attach later. The cluster
|
|
5
|
+
credential the service uses downstream is held server-side and never travels
|
|
6
|
+
through this edge.
|
|
7
|
+
|
|
8
|
+
Implemented as pure ASGI (not ``BaseHTTPMiddleware``) so it does not buffer the
|
|
9
|
+
streaming MCP responses.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from ipaddress import IPv6Address
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from starlette.datastructures import Headers
|
|
17
|
+
from starlette.responses import JSONResponse
|
|
18
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
19
|
+
|
|
20
|
+
from sie_mcp.config import MCPConfig
|
|
21
|
+
|
|
22
|
+
# The OAuth bridge endpoints bootstrap auth for claude.ai connectors, so they sit
|
|
23
|
+
# in front of the connector-secret gate. Metadata + DCR + authorize + token
|
|
24
|
+
# must all be reachable unauthenticated.
|
|
25
|
+
_EXEMPT_PATHS = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"/healthz",
|
|
28
|
+
"/.well-known/oauth-protected-resource",
|
|
29
|
+
"/.well-known/oauth-protected-resource/mcp",
|
|
30
|
+
"/.well-known/oauth-authorization-server",
|
|
31
|
+
"/register",
|
|
32
|
+
"/authorize",
|
|
33
|
+
"/token",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_LOOPBACK_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "[::1]"})
|
|
39
|
+
|
|
40
|
+
_MAX_PORT = 65535
|
|
41
|
+
|
|
42
|
+
# RFC 9110 s7.2: a Host header is "uri-host [ ':' port ]" and carries no userinfo,
|
|
43
|
+
# path, query, or fragment.
|
|
44
|
+
_HOST_RE = re.compile(
|
|
45
|
+
r"""
|
|
46
|
+
\A
|
|
47
|
+
(?:
|
|
48
|
+
\[(?P<ipv6>[0-9a-f:.]+)\]
|
|
49
|
+
| (?P<regname>[0-9a-z._~!$&'()*+,;=%-]+)
|
|
50
|
+
)
|
|
51
|
+
(?::(?P<port>[0-9]{1,5}))?
|
|
52
|
+
\Z
|
|
53
|
+
""",
|
|
54
|
+
re.VERBOSE,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _split_host_port(host: str) -> tuple[str, str | None] | None:
|
|
59
|
+
"""Split a lowercased ``Host`` into ``(hostname, port)``, or ``None`` if malformed."""
|
|
60
|
+
match = _HOST_RE.fullmatch(host)
|
|
61
|
+
if match is None:
|
|
62
|
+
return None
|
|
63
|
+
port = match["port"]
|
|
64
|
+
if port is not None and not 1 <= int(port) <= _MAX_PORT:
|
|
65
|
+
return None
|
|
66
|
+
ipv6 = match["ipv6"]
|
|
67
|
+
if ipv6 is not None:
|
|
68
|
+
try:
|
|
69
|
+
IPv6Address(ipv6)
|
|
70
|
+
except ValueError:
|
|
71
|
+
return None
|
|
72
|
+
return (f"[{ipv6}]" if ipv6 is not None else match["regname"]), port
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _host_trusted(config: MCPConfig, host: str) -> bool:
|
|
76
|
+
host = host.lower()
|
|
77
|
+
parsed = _split_host_port(host)
|
|
78
|
+
if parsed is None:
|
|
79
|
+
return False
|
|
80
|
+
hostname, port = parsed
|
|
81
|
+
if hostname in _LOOPBACK_HOSTNAMES:
|
|
82
|
+
return True
|
|
83
|
+
for allowed in (entry.lower() for entry in config.allowed_hosts):
|
|
84
|
+
if host == allowed:
|
|
85
|
+
return True
|
|
86
|
+
if allowed.endswith(":*") and port is not None and hostname == allowed[:-2]:
|
|
87
|
+
return True
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def base_url(config: MCPConfig, *, scheme: str, headers: Headers) -> str | None:
|
|
92
|
+
"""Resolve the externally reachable origin for OAuth metadata URLs.
|
|
93
|
+
|
|
94
|
+
Prefers the pinned ``SIE_MCP_PUBLIC_URL``. Unpinned, the request's own scheme and
|
|
95
|
+
``Host`` are used only when the host is loopback or listed in
|
|
96
|
+
``SIE_MCP_ALLOWED_HOSTS``; otherwise ``None``. This origin names the authorization
|
|
97
|
+
server clients trust, so caller-controlled ``Host`` and ``X-Forwarded-*`` values are
|
|
98
|
+
never advertised. A proxy-set scheme is honoured only through uvicorn's
|
|
99
|
+
``FORWARDED_ALLOW_IPS`` trust list, which rewrites ``scheme`` upstream.
|
|
100
|
+
"""
|
|
101
|
+
if config.public_base_url:
|
|
102
|
+
return config.public_base_url
|
|
103
|
+
host = headers.get("host") or ""
|
|
104
|
+
if not host or not _host_trusted(config, host):
|
|
105
|
+
return None
|
|
106
|
+
return f"{scheme}://{host}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def bearer_token(authorization: str | None) -> str | None:
|
|
110
|
+
if not authorization:
|
|
111
|
+
return None
|
|
112
|
+
value = authorization.strip()
|
|
113
|
+
if not value:
|
|
114
|
+
return None
|
|
115
|
+
if value[:7].lower() == "bearer ":
|
|
116
|
+
return value[7:].strip() or None
|
|
117
|
+
# A bare "Bearer" scheme with no token is not a credential.
|
|
118
|
+
if value.lower() == "bearer":
|
|
119
|
+
return None
|
|
120
|
+
# Otherwise accept a raw token sent without the scheme prefix.
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def authenticate(config: MCPConfig, token: str | None) -> str | None:
|
|
125
|
+
"""Return a user identity for the token, or ``None`` to reject the request."""
|
|
126
|
+
if config.connector_secrets:
|
|
127
|
+
if token and token in config.connector_secrets:
|
|
128
|
+
return config.connector_secrets[token]
|
|
129
|
+
if not token and config.allow_anonymous:
|
|
130
|
+
return "anonymous"
|
|
131
|
+
return None
|
|
132
|
+
# No secrets configured: open only when anonymous access is explicitly allowed.
|
|
133
|
+
return "anonymous" if config.allow_anonymous else None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ConnectorSecretAuthMiddleware:
|
|
137
|
+
"""Pure-ASGI auth gate that maps a connector secret to a user identity."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, app: ASGIApp, config: MCPConfig) -> None:
|
|
140
|
+
self._app = app
|
|
141
|
+
self._config = config
|
|
142
|
+
|
|
143
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
144
|
+
if scope["type"] != "http" or scope.get("path") in _EXEMPT_PATHS:
|
|
145
|
+
await self._app(scope, receive, send)
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
headers = Headers(scope=scope)
|
|
149
|
+
token = bearer_token(headers.get("authorization"))
|
|
150
|
+
identity = authenticate(self._config, token)
|
|
151
|
+
if identity is None:
|
|
152
|
+
response = JSONResponse(
|
|
153
|
+
{"error": "unauthorized"},
|
|
154
|
+
status_code=401,
|
|
155
|
+
headers=self._challenge_headers(scope, headers),
|
|
156
|
+
)
|
|
157
|
+
await response(scope, receive, send)
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
state: dict[str, Any] = dict(scope.get("state") or {})
|
|
161
|
+
state["user_id"] = identity
|
|
162
|
+
scope["state"] = state
|
|
163
|
+
await self._app(scope, receive, send)
|
|
164
|
+
|
|
165
|
+
def _challenge_headers(self, scope: Scope, headers: Headers) -> dict[str, str]:
|
|
166
|
+
"""Point unauthenticated clients at the OAuth bridge (RFC 9728)."""
|
|
167
|
+
if not self._config.oauth_enabled:
|
|
168
|
+
return {}
|
|
169
|
+
origin = base_url(self._config, scheme=scope.get("scheme", "http"), headers=headers)
|
|
170
|
+
if origin is None:
|
|
171
|
+
return {}
|
|
172
|
+
metadata = f"{origin}/.well-known/oauth-protected-resource"
|
|
173
|
+
return {"WWW-Authenticate": f'Bearer resource_metadata="{metadata}"'}
|
sie_mcp/chunking.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Transient text chunking for ``answer_questions``.
|
|
2
|
+
|
|
3
|
+
SIE has no chunking pipeline today, so this is a deliberately small,
|
|
4
|
+
self-contained primitive: a fixed-size sliding character window with overlap. It
|
|
5
|
+
runs entirely in the MCP edge per request — chunks are never persisted and no
|
|
6
|
+
index is built, preserving a transient retrieval boundary.
|
|
7
|
+
|
|
8
|
+
Character windows (not model-tokenizer windows) keep the job dependency-free and
|
|
9
|
+
predictable; sizes are expressed in characters and approximate a token budget at
|
|
10
|
+
the ~4-chars-per-token heuristic the edge already uses elsewhere. Precise token
|
|
11
|
+
accounting is the cluster's job, not the edge's.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Chunk:
|
|
20
|
+
"""A single passage carved from one source document.
|
|
21
|
+
|
|
22
|
+
``id`` is stable within a single call (``d{doc}-c{chunk}``) so the reranker
|
|
23
|
+
can echo it back; ``doc_index`` and ``start`` point back to the source so a
|
|
24
|
+
caller can locate the passage in the original text.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
text: str
|
|
29
|
+
doc_index: int
|
|
30
|
+
start: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def chunk_text(text: str, *, window: int, overlap: int) -> list[tuple[int, str]]:
|
|
34
|
+
"""Slide a fixed character window over ``text``, yielding ``(start, piece)``.
|
|
35
|
+
|
|
36
|
+
``overlap`` characters are shared between neighbouring windows so a passage
|
|
37
|
+
straddling a boundary still lands whole in at least one window. Returns an
|
|
38
|
+
empty list for empty text.
|
|
39
|
+
"""
|
|
40
|
+
if window <= 0:
|
|
41
|
+
msg = f"window must be positive, got {window}"
|
|
42
|
+
raise ValueError(msg)
|
|
43
|
+
if not 0 <= overlap < window:
|
|
44
|
+
msg = f"overlap must satisfy 0 <= overlap < window, got overlap={overlap}, window={window}"
|
|
45
|
+
raise ValueError(msg)
|
|
46
|
+
stride = window - overlap
|
|
47
|
+
windows: list[tuple[int, str]] = []
|
|
48
|
+
start = 0
|
|
49
|
+
length = len(text)
|
|
50
|
+
while start < length:
|
|
51
|
+
windows.append((start, text[start : start + window]))
|
|
52
|
+
if start + window >= length:
|
|
53
|
+
break
|
|
54
|
+
start += stride
|
|
55
|
+
return windows
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def chunk_documents(documents: Sequence[str], *, window: int, overlap: int) -> list[Chunk]:
|
|
59
|
+
"""Chunk a set of documents into overlapping windows.
|
|
60
|
+
|
|
61
|
+
Empty / whitespace-only documents contribute no chunks. Chunk ids are unique
|
|
62
|
+
within the returned list, so a reranker can echo them back unambiguously.
|
|
63
|
+
"""
|
|
64
|
+
chunks: list[Chunk] = []
|
|
65
|
+
for doc_index, document in enumerate(documents):
|
|
66
|
+
if not document or not document.strip():
|
|
67
|
+
continue
|
|
68
|
+
for chunk_index, (start, piece) in enumerate(chunk_text(document, window=window, overlap=overlap)):
|
|
69
|
+
chunks.append(Chunk(id=f"d{doc_index}-c{chunk_index}", text=piece, doc_index=doc_index, start=start))
|
|
70
|
+
return chunks
|
sie_mcp/cli.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""CLI entry point for the SIE MCP edge service."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from importlib.metadata import PackageNotFoundError
|
|
5
|
+
from importlib.metadata import version as _pkg_version
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
import uvicorn
|
|
11
|
+
|
|
12
|
+
from sie_mcp.plugin_pack import build_plugin_pack
|
|
13
|
+
from sie_mcp.skill_zip import build_skill_zip, skill_name
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(name="sie-mcp", help="SIE MCP edge service.", no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
_PACKAGE_ROOT = Path(__file__).resolve().parents[2]
|
|
18
|
+
_DEFAULT_SKILL_MD = _PACKAGE_ROOT / "plugin" / "SKILL.md"
|
|
19
|
+
_DEFAULT_COWORK_GUIDE = _PACKAGE_ROOT / "plugin" / "superlinked.md"
|
|
20
|
+
_DEFAULT_CLAUDE_CODE_SKILLS_DIR = _PACKAGE_ROOT / "plugin" / "claude-code"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _setup_logging(level: str) -> None:
|
|
24
|
+
logging.basicConfig(
|
|
25
|
+
level=level.upper(),
|
|
26
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def serve(
|
|
32
|
+
host: Annotated[str, typer.Option("--host", "-h", help="Host to bind to.")] = "0.0.0.0", # noqa: S104 — intentional bind to all interfaces for the edge service
|
|
33
|
+
port: Annotated[int, typer.Option("--port", "-p", help="Port to listen on.")] = 8088,
|
|
34
|
+
log_level: Annotated[
|
|
35
|
+
str,
|
|
36
|
+
typer.Option("--log-level", "-l", envvar="SIE_LOG_LEVEL", help="Log level."),
|
|
37
|
+
] = "info",
|
|
38
|
+
reload: Annotated[
|
|
39
|
+
bool,
|
|
40
|
+
typer.Option("--reload", "-r", help="Auto-reload for development."),
|
|
41
|
+
] = False,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Start the MCP edge over remote streamable-HTTP."""
|
|
44
|
+
_setup_logging(log_level)
|
|
45
|
+
uvicorn.run(
|
|
46
|
+
"sie_mcp.app:build_app",
|
|
47
|
+
host=host,
|
|
48
|
+
port=port,
|
|
49
|
+
factory=True,
|
|
50
|
+
reload=reload,
|
|
51
|
+
log_level=log_level.lower(),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command("skill-zip")
|
|
56
|
+
def skill_zip(
|
|
57
|
+
skill: Annotated[
|
|
58
|
+
Path,
|
|
59
|
+
typer.Option(
|
|
60
|
+
"--skill", "-s", help="Path to the SKILL.md to package (default: the surface-agnostic plugin/SKILL.md)."
|
|
61
|
+
),
|
|
62
|
+
] = _DEFAULT_SKILL_MD,
|
|
63
|
+
out: Annotated[
|
|
64
|
+
Path | None,
|
|
65
|
+
typer.Option("--out", "-o", help="Output .zip path (default: dist/<name>-skill.zip)."),
|
|
66
|
+
] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Package the surface-agnostic Superlinked Agent Skill as a claude.ai-uploadable ZIP."""
|
|
69
|
+
if not skill.is_file():
|
|
70
|
+
raise typer.BadParameter(f"SKILL.md not found at {skill}")
|
|
71
|
+
text = skill.read_text(encoding="utf-8")
|
|
72
|
+
data = build_skill_zip(text)
|
|
73
|
+
target = out or (_PACKAGE_ROOT / "dist" / f"{skill_name(text)}-skill.zip")
|
|
74
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
target.write_bytes(data)
|
|
76
|
+
typer.echo(f"wrote {target} ({len(data)} bytes)")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command("plugin-pack")
|
|
80
|
+
def plugin_pack(
|
|
81
|
+
mcp_url: Annotated[
|
|
82
|
+
str,
|
|
83
|
+
typer.Option(
|
|
84
|
+
"--mcp-url",
|
|
85
|
+
envvar="SIE_MCP_URL",
|
|
86
|
+
help="Hosted or self-hosted MCP endpoint to install, e.g. https://mcp.example.com/mcp.",
|
|
87
|
+
),
|
|
88
|
+
],
|
|
89
|
+
connector_secret: Annotated[
|
|
90
|
+
str | None,
|
|
91
|
+
typer.Option(
|
|
92
|
+
"--connector-secret",
|
|
93
|
+
envvar="SIE_MCP_CONNECTOR_SECRET",
|
|
94
|
+
help=(
|
|
95
|
+
"Deprecated compatibility input; ignored and never used, written, or printed. "
|
|
96
|
+
"Provide the secret during connector installation."
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
] = None,
|
|
100
|
+
cluster_label: Annotated[
|
|
101
|
+
str,
|
|
102
|
+
typer.Option("--cluster-label", help="Human-readable cluster label for the generated install guide."),
|
|
103
|
+
] = "sie-cluster",
|
|
104
|
+
skill: Annotated[
|
|
105
|
+
Path,
|
|
106
|
+
typer.Option("--skill", "-s", help="Path to the surface-agnostic Superlinked SKILL.md to package."),
|
|
107
|
+
] = _DEFAULT_SKILL_MD,
|
|
108
|
+
cowork_guide: Annotated[
|
|
109
|
+
Path | None,
|
|
110
|
+
typer.Option("--cowork-guide", help="Optional Cowork install guide to include in the pack."),
|
|
111
|
+
] = _DEFAULT_COWORK_GUIDE,
|
|
112
|
+
claude_code_skills_dir: Annotated[
|
|
113
|
+
Path | None,
|
|
114
|
+
typer.Option(
|
|
115
|
+
"--claude-code-skills-dir",
|
|
116
|
+
help="Optional directory of Claude Code skill folders to include in the pack.",
|
|
117
|
+
),
|
|
118
|
+
] = _DEFAULT_CLAUDE_CODE_SKILLS_DIR,
|
|
119
|
+
out_dir: Annotated[
|
|
120
|
+
Path,
|
|
121
|
+
typer.Option("--out-dir", "-o", help="Output directory for the plugin pack."),
|
|
122
|
+
] = _PACKAGE_ROOT / "dist" / "superlinked-docs-plugin",
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Build a quick install pack for a hosted or self-hosted Superlinked MCP edge."""
|
|
125
|
+
if not skill.is_file():
|
|
126
|
+
raise typer.BadParameter(f"SKILL.md not found at {skill}")
|
|
127
|
+
cowork_text = None
|
|
128
|
+
if cowork_guide is not None:
|
|
129
|
+
if not cowork_guide.is_file():
|
|
130
|
+
raise typer.BadParameter(f"Cowork guide not found at {cowork_guide}")
|
|
131
|
+
cowork_text = cowork_guide.read_text(encoding="utf-8")
|
|
132
|
+
claude_code_skill_mds = []
|
|
133
|
+
if claude_code_skills_dir is not None:
|
|
134
|
+
if not claude_code_skills_dir.is_dir():
|
|
135
|
+
raise typer.BadParameter(f"Claude Code skills directory not found at {claude_code_skills_dir}")
|
|
136
|
+
claude_code_skill_mds = [
|
|
137
|
+
skill_file.read_text(encoding="utf-8") for skill_file in sorted(claude_code_skills_dir.glob("*/SKILL.md"))
|
|
138
|
+
]
|
|
139
|
+
try:
|
|
140
|
+
report = build_plugin_pack(
|
|
141
|
+
skill.read_text(encoding="utf-8"),
|
|
142
|
+
mcp_url=mcp_url,
|
|
143
|
+
connector_secret=connector_secret,
|
|
144
|
+
cluster_label=cluster_label,
|
|
145
|
+
claude_code_skill_mds=claude_code_skill_mds,
|
|
146
|
+
cowork_guide_md=cowork_text,
|
|
147
|
+
out_dir=out_dir,
|
|
148
|
+
)
|
|
149
|
+
except ValueError as exc:
|
|
150
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
151
|
+
|
|
152
|
+
typer.echo(f"wrote plugin pack to {report.out_dir}")
|
|
153
|
+
typer.echo(f"- install guide: {report.install_guide}")
|
|
154
|
+
typer.echo(f"- claude.ai skill ZIP: {report.skill_zip}")
|
|
155
|
+
for skill_path in report.claude_code_skills:
|
|
156
|
+
typer.echo(f"- Claude Code skill: {skill_path}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.command()
|
|
160
|
+
def version() -> None:
|
|
161
|
+
"""Show version information."""
|
|
162
|
+
try:
|
|
163
|
+
typer.echo(f"sie-mcp {_pkg_version('sie-mcp')}")
|
|
164
|
+
except PackageNotFoundError:
|
|
165
|
+
typer.echo("sie-mcp (version unknown)")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main() -> None:
|
|
169
|
+
app()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
main()
|