specstream-mcp-stdio 1.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,14 @@
1
+ node_modules/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ .venv/
7
+ .pytest_cache/
8
+ *.tsbuildinfo
9
+ *.tgz
10
+ *.whl
11
+ .env
12
+ .env.*
13
+ !.env.example
14
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SpecStream SAS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.5
2
+ Name: specstream-mcp-stdio
3
+ Version: 1.1.0
4
+ Summary: Stdio proxy for the SpecStream MCP server, for Zed, Cline, and Continue. Finished lists with sources, priced before they run.
5
+ Project-URL: Homepage, https://specstream.ai
6
+ Project-URL: Documentation, https://specstream.ai/docs/mcp
7
+ Project-URL: Repository, https://github.com/Specstreamai/specstream-tools
8
+ Project-URL: Issues, https://github.com/Specstreamai/specstream-tools/issues
9
+ Author-email: SpecStream <support@specstream.ai>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai-agents,mcp,model-context-protocol,proxy,specstream,stdio,web-data
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Internet :: WWW/HTTP
22
+ Requires-Python: >=3.12
23
+ Description-Content-Type: text/markdown
24
+
25
+ # specstream-mcp-stdio
26
+
27
+ A stdio proxy for the [SpecStream](https://specstream.ai) MCP server at `https://api.specstream.ai/mcp`. Use it with clients that only start local servers, such as Zed, Cline and Continue. Clients that connect to a remote URL do not need it.
28
+
29
+ The proxy forwards to the same Streamable HTTP service that remote clients use, so your agent works on your account: the same balance, permissions and spending limits you have.
30
+
31
+ ## Install
32
+
33
+ Python 3.12 or newer. The package has no dependencies.
34
+
35
+ ```sh
36
+ pip install specstream-mcp-stdio
37
+ ```
38
+
39
+ The command is `specstream-mcp-stdio`. In a GUI client, use its absolute path if the environment it was installed into is not on `PATH`.
40
+
41
+ Create an API key under **Agents & API** in your SpecStream workspace. Set `SPECSTREAM_API_KEY` in the private environment of the client, outside prompts and shared configuration. `SPECSTREAM_MCP_URL` is an optional endpoint override.
42
+
43
+ ## Client configuration
44
+
45
+ For clients that take an `mcpServers` entry:
46
+
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "specstream": {
51
+ "command": "specstream-mcp-stdio",
52
+ "args": [],
53
+ "env": { "SPECSTREAM_MCP_URL": "https://api.specstream.ai/mcp" }
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ Zed uses `context_servers` with the same fields. Continue takes a YAML file:
60
+
61
+ ```yaml
62
+ name: SpecStream
63
+ version: 1.0.0
64
+ schema: v1
65
+ mcpServers:
66
+ - name: specstream
67
+ type: stdio
68
+ command: specstream-mcp-stdio
69
+ args: []
70
+ env:
71
+ SPECSTREAM_MCP_URL: https://api.specstream.ai/mcp
72
+ SPECSTREAM_API_KEY: ${{ secrets.SPECSTREAM_API_KEY }}
73
+ ```
74
+
75
+ ## Use the tools
76
+
77
+ Reconnect your client and list the tools. Start with `compile` to read a request, or `list_specs` and `list_projects` to find saved work.
78
+
79
+ New paid work goes `quote_capacity_work`, then `authorize_capacity_work` with the quote ID and hash, the approved maximum in `confirm_max_cents` and a stable request key. Reading saved results does not start more paid work. See the [agent guide](https://specstream.ai/docs/mcp).
80
+
81
+ ## Connection problems
82
+
83
+ The proxy needs the endpoint and a valid key. If it cannot connect, check the client's environment and the key's permissions. Keep TLS verification on.
84
+
85
+ Protocol messages use stdin and stdout; diagnostics use stderr. The proxy keeps the negotiated HTTP session and does not follow redirects with credentials.
86
+
87
+ Source and issues: [github.com/Specstreamai/specstream-tools](https://github.com/Specstreamai/specstream-tools). MIT license.
@@ -0,0 +1,63 @@
1
+ # specstream-mcp-stdio
2
+
3
+ A stdio proxy for the [SpecStream](https://specstream.ai) MCP server at `https://api.specstream.ai/mcp`. Use it with clients that only start local servers, such as Zed, Cline and Continue. Clients that connect to a remote URL do not need it.
4
+
5
+ The proxy forwards to the same Streamable HTTP service that remote clients use, so your agent works on your account: the same balance, permissions and spending limits you have.
6
+
7
+ ## Install
8
+
9
+ Python 3.12 or newer. The package has no dependencies.
10
+
11
+ ```sh
12
+ pip install specstream-mcp-stdio
13
+ ```
14
+
15
+ The command is `specstream-mcp-stdio`. In a GUI client, use its absolute path if the environment it was installed into is not on `PATH`.
16
+
17
+ Create an API key under **Agents & API** in your SpecStream workspace. Set `SPECSTREAM_API_KEY` in the private environment of the client, outside prompts and shared configuration. `SPECSTREAM_MCP_URL` is an optional endpoint override.
18
+
19
+ ## Client configuration
20
+
21
+ For clients that take an `mcpServers` entry:
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "specstream": {
27
+ "command": "specstream-mcp-stdio",
28
+ "args": [],
29
+ "env": { "SPECSTREAM_MCP_URL": "https://api.specstream.ai/mcp" }
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ Zed uses `context_servers` with the same fields. Continue takes a YAML file:
36
+
37
+ ```yaml
38
+ name: SpecStream
39
+ version: 1.0.0
40
+ schema: v1
41
+ mcpServers:
42
+ - name: specstream
43
+ type: stdio
44
+ command: specstream-mcp-stdio
45
+ args: []
46
+ env:
47
+ SPECSTREAM_MCP_URL: https://api.specstream.ai/mcp
48
+ SPECSTREAM_API_KEY: ${{ secrets.SPECSTREAM_API_KEY }}
49
+ ```
50
+
51
+ ## Use the tools
52
+
53
+ Reconnect your client and list the tools. Start with `compile` to read a request, or `list_specs` and `list_projects` to find saved work.
54
+
55
+ New paid work goes `quote_capacity_work`, then `authorize_capacity_work` with the quote ID and hash, the approved maximum in `confirm_max_cents` and a stable request key. Reading saved results does not start more paid work. See the [agent guide](https://specstream.ai/docs/mcp).
56
+
57
+ ## Connection problems
58
+
59
+ The proxy needs the endpoint and a valid key. If it cannot connect, check the client's environment and the key's permissions. Keep TLS verification on.
60
+
61
+ Protocol messages use stdin and stdout; diagnostics use stderr. The proxy keeps the negotiated HTTP session and does not follow redirects with credentials.
62
+
63
+ Source and issues: [github.com/Specstreamai/specstream-tools](https://github.com/Specstreamai/specstream-tools). MIT license.
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "specstream-mcp-stdio"
3
+ version = "1.1.0"
4
+ description = "Stdio proxy for the SpecStream MCP server, for Zed, Cline, and Continue. Finished lists with sources, priced before they run."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "SpecStream", email = "support@specstream.ai" }]
10
+ keywords = [
11
+ "specstream",
12
+ "mcp",
13
+ "model-context-protocol",
14
+ "stdio",
15
+ "proxy",
16
+ "ai-agents",
17
+ "web-data",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 5 - Production/Stable",
21
+ "Environment :: Console",
22
+ "Intended Audience :: Developers",
23
+ "Operating System :: OS Independent",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3 :: Only",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Internet :: WWW/HTTP",
29
+ ]
30
+ dependencies = []
31
+
32
+ [project.urls]
33
+ Homepage = "https://specstream.ai"
34
+ Documentation = "https://specstream.ai/docs/mcp"
35
+ Repository = "https://github.com/Specstreamai/specstream-tools"
36
+ Issues = "https://github.com/Specstreamai/specstream-tools/issues"
37
+
38
+ [project.scripts]
39
+ specstream-mcp-stdio = "specstream_mcp_stdio.proxy:main"
40
+
41
+ [build-system]
42
+ requires = ["hatchling>=1.27,<2"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["specstream_mcp_stdio"]
47
+
48
+ [tool.hatch.build.targets.sdist]
49
+ include = ["specstream_mcp_stdio", "README.md", "LICENSE"]
@@ -0,0 +1,5 @@
1
+ """SPECSTREAM MCP stdio proxy. Bearer from environment only."""
2
+
3
+ from specstream_mcp_stdio.proxy import authorization_from_env, main
4
+
5
+ __all__ = ["authorization_from_env", "main"]
@@ -0,0 +1,4 @@
1
+ from specstream_mcp_stdio.proxy import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,259 @@
1
+ """Stdio to Streamable HTTP proxy. Authorization is copied from env only.
2
+
3
+ Both MCP eras pass through. A request that carries its protocol version in
4
+ `params._meta` (2026-07-28 and later) is forwarded statelessly with the version, method
5
+ and name mirrored into headers. A client that starts with `initialize` gets the session
6
+ revision the server negotiates, and its session id is kept for later requests.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import ipaddress
13
+ import json
14
+ import os
15
+ import sys
16
+ import urllib.error
17
+ import urllib.request
18
+ from collections.abc import Mapping
19
+ from typing import Any, TextIO
20
+ from urllib.parse import urlsplit
21
+
22
+ DEFAULT_MCP_URL = "https://api.specstream.ai/mcp"
23
+ PROTOCOL_VERSION = "2025-11-25"
24
+ VERSION_KEY = "io.modelcontextprotocol/protocolVersion"
25
+ NAMED_METHODS = {"tools/call": "name", "prompts/get": "name", "resources/read": "uri"}
26
+ MISSING_KEY = "SPECSTREAM_API_KEY is required. MCP does not elicit keys."
27
+
28
+
29
+ def authorization_from_env(env: Mapping[str, str]) -> str:
30
+ for name in ("SPECSTREAM_API_KEY", "SPECSTREAM_AUTHORIZATION", "AUTHORIZATION"):
31
+ value = (env.get(name) or "").strip()
32
+ if value:
33
+ if any(not 32 <= ord(char) < 127 for char in value):
34
+ raise ValueError("The configured authorization header contains invalid characters")
35
+ return value if value.lower().startswith("bearer ") else f"Bearer {value}"
36
+ raise KeyError("SPECSTREAM_API_KEY")
37
+
38
+
39
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
40
+ def redirect_request(
41
+ self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str
42
+ ) -> None:
43
+ return None
44
+
45
+
46
+ def _mcp_url(env: Mapping[str, str]) -> str:
47
+ value = (env.get("SPECSTREAM_MCP_URL") or DEFAULT_MCP_URL).strip() or DEFAULT_MCP_URL
48
+ try:
49
+ parsed = urlsplit(value)
50
+ host = parsed.hostname
51
+ port = parsed.port
52
+ try:
53
+ loopback = host is not None and ipaddress.ip_address(host).is_loopback
54
+ except ValueError:
55
+ loopback = host == "localhost"
56
+ if (
57
+ not host
58
+ or parsed.scheme not in {"http", "https"}
59
+ or parsed.scheme == "http"
60
+ and not loopback
61
+ or parsed.username is not None
62
+ or parsed.password is not None
63
+ or parsed.query
64
+ or parsed.fragment
65
+ or port == 0
66
+ or any(ord(char) <= 32 or ord(char) == 127 for char in value)
67
+ ):
68
+ raise ValueError
69
+ except ValueError:
70
+ raise ValueError(
71
+ "SPECSTREAM_MCP_URL must use HTTPS (or loopback HTTP), "
72
+ "without credentials, query or fragment"
73
+ ) from None
74
+ return value
75
+
76
+
77
+ def _header_value(value: str) -> str:
78
+ """Mcp-Name as the revision sends it: plain visible ASCII, or the Base64 sentinel form."""
79
+ plain = (
80
+ value == value.strip()
81
+ and all(32 <= ord(char) < 127 for char in value)
82
+ and not (value.startswith("=?base64?") and value.endswith("?="))
83
+ )
84
+ if plain:
85
+ return value
86
+ return "=?base64?" + base64.b64encode(value.encode("utf-8")).decode("ascii") + "?="
87
+
88
+
89
+ def _stateless_version(message: Mapping[str, Any]) -> str | None:
90
+ params = message.get("params")
91
+ meta = params.get("_meta") if isinstance(params, Mapping) else None
92
+ version = meta.get(VERSION_KEY) if isinstance(meta, Mapping) else None
93
+ return version if isinstance(version, str) and version else None
94
+
95
+
96
+ def _json_from_sse(text: str, request_id: Any) -> dict[str, Any]:
97
+ for event in text.replace("\r\n", "\n").split("\n\n"):
98
+ blob = "\n".join(
99
+ line[5:].lstrip() for line in event.splitlines() if line.startswith("data:")
100
+ )
101
+ if not blob:
102
+ continue
103
+ payload = json.loads(blob)
104
+ if isinstance(payload, dict) and "id" in payload and payload["id"] == request_id:
105
+ return payload
106
+ raise ValueError("mcp returned no response for the request")
107
+
108
+
109
+ def _post_json(
110
+ url: str,
111
+ message: dict[str, Any],
112
+ *,
113
+ authorization: str,
114
+ session_id: str | None,
115
+ session_version: str = PROTOCOL_VERSION,
116
+ ) -> tuple[int, dict[str, str], dict[str, Any] | None]:
117
+ payload = json.dumps(message, separators=(",", ":")).encode("utf-8")
118
+ request = urllib.request.Request(url, data=payload, method="POST")
119
+ request.add_header("Authorization", authorization)
120
+ request.add_header("Accept", "application/json, text/event-stream")
121
+ request.add_header("Content-Type", "application/json")
122
+ stateless = _stateless_version(message)
123
+ if stateless:
124
+ method = str(message.get("method", ""))
125
+ request.add_header("MCP-Protocol-Version", stateless)
126
+ request.add_header("Mcp-Method", method)
127
+ params = message.get("params")
128
+ field = NAMED_METHODS.get(method)
129
+ name = params.get(field) if field and isinstance(params, Mapping) else None
130
+ if isinstance(name, str):
131
+ request.add_header("Mcp-Name", _header_value(name))
132
+ else:
133
+ request.add_header("MCP-Protocol-Version", session_version)
134
+ if session_id:
135
+ request.add_header("MCP-Session-Id", session_id)
136
+ try:
137
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect())
138
+ with opener.open(request, timeout=60) as response:
139
+ status = int(response.status)
140
+ headers = {key.lower(): value for key, value in response.headers.items()}
141
+ raw = response.read().decode("utf-8")
142
+ except urllib.error.HTTPError as exc:
143
+ status = int(exc.code)
144
+ header_items = exc.headers.items() if exc.headers else []
145
+ headers = {key.lower(): value for key, value in header_items}
146
+ raw = exc.read().decode("utf-8") if exc.fp is not None else ""
147
+ if not 200 <= status < 300:
148
+ # The 2026-07-28 revision answers version, header and method problems with a JSON-RPC
149
+ # error on a 4xx; the client needs it to pick another version or fall back.
150
+ error = _error_body(raw, message.get("id"))
151
+ if error is None:
152
+ raise ValueError(f"mcp http {status}")
153
+ return status, headers, error
154
+ if status == 202:
155
+ if "id" in message:
156
+ raise ValueError("mcp returned no response for the request")
157
+ return status, headers, None
158
+ if not raw:
159
+ raise ValueError("mcp returned an empty response")
160
+ content_type = headers.get("content-type", "")
161
+ if "text/event-stream" in content_type:
162
+ loaded = _json_from_sse(raw, message.get("id"))
163
+ else:
164
+ loaded = json.loads(raw)
165
+ if (
166
+ not isinstance(loaded, dict)
167
+ or loaded.get("jsonrpc") != "2.0"
168
+ or "id" not in loaded
169
+ or loaded["id"] != message.get("id")
170
+ or not ({"result", "error"} & loaded.keys())
171
+ ):
172
+ raise ValueError("mcp returned an invalid response")
173
+ return status, headers, loaded
174
+
175
+
176
+ def _error_body(raw: str, request_id: Any) -> dict[str, Any] | None:
177
+ try:
178
+ loaded = json.loads(raw) if raw else None
179
+ except json.JSONDecodeError:
180
+ return None
181
+ if (
182
+ isinstance(loaded, dict)
183
+ and loaded.get("jsonrpc") == "2.0"
184
+ and "id" in loaded
185
+ and loaded["id"] == request_id
186
+ and isinstance(loaded.get("error"), dict)
187
+ ):
188
+ return loaded
189
+ return None
190
+
191
+
192
+ def main(
193
+ argv: list[str] | None = None,
194
+ env: Mapping[str, str] | None = None,
195
+ stdin: TextIO | None = None,
196
+ stdout: TextIO | None = None,
197
+ stderr: TextIO | None = None,
198
+ ) -> int:
199
+ del argv
200
+ environ = env if env is not None else os.environ
201
+ in_stream = stdin if stdin is not None else sys.stdin
202
+ out_stream = stdout if stdout is not None else sys.stdout
203
+ err_stream = stderr if stderr is not None else sys.stderr
204
+ try:
205
+ authorization = authorization_from_env(environ)
206
+ url = _mcp_url(environ)
207
+ except KeyError:
208
+ print(MISSING_KEY, file=err_stream)
209
+ return 2
210
+ except ValueError as exc:
211
+ print(str(exc), file=err_stream)
212
+ return 2
213
+ session_id: str | None = None
214
+ session_version = PROTOCOL_VERSION
215
+ for line in in_stream:
216
+ text = line.strip()
217
+ if not text:
218
+ continue
219
+ try:
220
+ message = json.loads(text)
221
+ except json.JSONDecodeError:
222
+ print("invalid jsonrpc line", file=err_stream)
223
+ return 1
224
+ if not isinstance(message, dict):
225
+ print("invalid jsonrpc line", file=err_stream)
226
+ return 1
227
+ if message.get("method") == "notifications/cancelled" and "id" not in message:
228
+ # Each request is a blocking POST here, so there is nothing to cancel upstream.
229
+ continue
230
+ try:
231
+ status, headers, body = _post_json(
232
+ url,
233
+ message,
234
+ authorization=authorization,
235
+ session_id=session_id,
236
+ session_version=session_version,
237
+ )
238
+ except (ValueError, OSError) as exc:
239
+ print(f"mcp transport failed: {exc}", file=err_stream)
240
+ return 1
241
+ header_session = headers.get("mcp-session-id")
242
+ if header_session and not _stateless_version(message):
243
+ session_id = header_session
244
+ if message.get("method") == "initialize" and isinstance(body, dict):
245
+ negotiated = (body.get("result") or {}).get("protocolVersion")
246
+ if isinstance(negotiated, str) and negotiated:
247
+ session_version = negotiated
248
+ if body is None:
249
+ if status >= 400:
250
+ print(f"mcp http {status}", file=err_stream)
251
+ return 1
252
+ continue
253
+ out_stream.write(json.dumps(body, separators=(",", ":")) + "\n")
254
+ out_stream.flush()
255
+ return 0
256
+
257
+
258
+ if __name__ == "__main__":
259
+ raise SystemExit(main())