specstream-mcp-stdio 1.1.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.
- specstream_mcp_stdio/__init__.py +5 -0
- specstream_mcp_stdio/__main__.py +4 -0
- specstream_mcp_stdio/proxy.py +259 -0
- specstream_mcp_stdio-1.1.0.dist-info/METADATA +87 -0
- specstream_mcp_stdio-1.1.0.dist-info/RECORD +8 -0
- specstream_mcp_stdio-1.1.0.dist-info/WHEEL +4 -0
- specstream_mcp_stdio-1.1.0.dist-info/entry_points.txt +2 -0
- specstream_mcp_stdio-1.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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())
|
|
@@ -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,8 @@
|
|
|
1
|
+
specstream_mcp_stdio/__init__.py,sha256=ewGvSNMoTL0JDIUc5U_0N166EFeBenDbMynpHNYRqrE,179
|
|
2
|
+
specstream_mcp_stdio/__main__.py,sha256=fVAF8LCkKI3ly1ynnea5LhHYxTM9gUvK6AB5ECE-Iw0,101
|
|
3
|
+
specstream_mcp_stdio/proxy.py,sha256=ByLJFWKWUr3SUS-YfeDlrhzWkurpfPT_Zd83wjWTH3c,9854
|
|
4
|
+
specstream_mcp_stdio-1.1.0.dist-info/METADATA,sha256=eCrR7s0U618t_O9mtNMj8Yc934uEpnzoIP1qbEJXw0c,3572
|
|
5
|
+
specstream_mcp_stdio-1.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
specstream_mcp_stdio-1.1.0.dist-info/entry_points.txt,sha256=Xuuibbea9KTKFUPWMX4W_5leFZfp9gpMZ0KoiCf7sEY,73
|
|
7
|
+
specstream_mcp_stdio-1.1.0.dist-info/licenses/LICENSE,sha256=lvzGtfzsxc8Xe1_0sabdiWTcSDiSg3jqaFKV9x3F4oo,1071
|
|
8
|
+
specstream_mcp_stdio-1.1.0.dist-info/RECORD,,
|
|
@@ -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.
|