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 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()