mcp-stdio 0.3.2__tar.gz → 0.3.3__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.
@@ -11,6 +11,16 @@ jobs:
11
11
  steps:
12
12
  - uses: actions/checkout@v6
13
13
 
14
+ - name: Verify __version__ matches tag
15
+ run: |
16
+ TAG="${GITHUB_REF_NAME#v}"
17
+ PKG_VERSION=$(python3 -c "import re; print(re.search(r'__version__ = \"([^\"]+)\"', open('src/mcp_stdio/__init__.py').read()).group(1))")
18
+ if [ "$TAG" != "$PKG_VERSION" ]; then
19
+ echo "::error::Tag v$TAG does not match __version__ $PKG_VERSION in src/mcp_stdio/__init__.py"
20
+ exit 1
21
+ fi
22
+ echo "Verified: tag v$TAG matches __version__ $PKG_VERSION"
23
+
14
24
  - name: Set up Python
15
25
  uses: actions/setup-python@v6
16
26
  with:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-stdio
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Stdio-to-HTTP relay for MCP servers
5
5
  Project-URL: Homepage, https://github.com/shigechika/mcp-stdio
6
6
  Project-URL: Issues, https://github.com/shigechika/mcp-stdio/issues
@@ -162,6 +162,7 @@ Works around known issues in Claude Code's HTTP transport:
162
162
  - **Missing Accept header** — servers return 406, misinterpreted as auth failure ([#42470](https://github.com/anthropics/claude-code/issues/42470))
163
163
  - **OAuth fallback loop** — Claude Code enters OAuth discovery even when not needed ([#34008](https://github.com/anthropics/claude-code/issues/34008), [#39271](https://github.com/anthropics/claude-code/issues/39271))
164
164
  - **Session lost after disconnect** — mcp-stdio recovers MCP sessions automatically on 404 ([#34498](https://github.com/anthropics/claude-code/issues/34498), [#38631](https://github.com/anthropics/claude-code/issues/38631))
165
+ - **OAuth scope omitted** — Claude Code sends no `scope` parameter in authorization requests, causing strict OAuth servers to reject the flow ([#4540](https://github.com/anthropics/claude-code/issues/4540)); mcp-stdio sends scopes via `--oauth-scope`
165
166
  - **Proxy settings ignored** — Claude Code does not respect `NO_PROXY` ([#34804](https://github.com/anthropics/claude-code/issues/34804)); mcp-stdio inherits proxy settings from httpx
166
167
 
167
168
  ## Features
@@ -134,7 +134,8 @@ Claude Code の HTTP transport の既知の問題を回避できます:
134
134
  - **Accept ヘッダーの欠落** — サーバーが 406 を返し、認証エラーと誤認される([#42470](https://github.com/anthropics/claude-code/issues/42470))
135
135
  - **OAuth フォールバックループ** — OAuth 不要なサーバーでも OAuth 検出が走る([#34008](https://github.com/anthropics/claude-code/issues/34008), [#39271](https://github.com/anthropics/claude-code/issues/39271))
136
136
  - **切断後にセッションが失われる** — mcp-stdio は 404 で MCP セッションを自動回復([#34498](https://github.com/anthropics/claude-code/issues/34498), [#38631](https://github.com/anthropics/claude-code/issues/38631))
137
- - **プロキシ設定が無視される** Claude Code `NO_PROXY` を尊重しない([#34804](https://github.com/anthropics/claude-code/issues/34804)); mcp-stdio は httpx 経由でプロキシ設定を��承
137
+ - **OAuth scope が送信されない** 認可リクエストに `scope` パラメータが含まれず、厳格な OAuth サーバーがフローを拒否する([#4540](https://github.com/anthropics/claude-code/issues/4540)); mcp-stdio は `--oauth-scope` でスコープを送信
138
+ - **プロキシ設定が無視される** — Claude Code が `NO_PROXY` を尊重しない([#34804](https://github.com/anthropics/claude-code/issues/34804)); mcp-stdio は httpx 経由でプロキシ設定を継承
138
139
 
139
140
  ## 機能
140
141
 
@@ -136,6 +136,7 @@ Works around known issues in Claude Code's HTTP transport:
136
136
  - **Missing Accept header** — servers return 406, misinterpreted as auth failure ([#42470](https://github.com/anthropics/claude-code/issues/42470))
137
137
  - **OAuth fallback loop** — Claude Code enters OAuth discovery even when not needed ([#34008](https://github.com/anthropics/claude-code/issues/34008), [#39271](https://github.com/anthropics/claude-code/issues/39271))
138
138
  - **Session lost after disconnect** — mcp-stdio recovers MCP sessions automatically on 404 ([#34498](https://github.com/anthropics/claude-code/issues/34498), [#38631](https://github.com/anthropics/claude-code/issues/38631))
139
+ - **OAuth scope omitted** — Claude Code sends no `scope` parameter in authorization requests, causing strict OAuth servers to reject the flow ([#4540](https://github.com/anthropics/claude-code/issues/4540)); mcp-stdio sends scopes via `--oauth-scope`
139
140
  - **Proxy settings ignored** — Claude Code does not respect `NO_PROXY` ([#34804](https://github.com/anthropics/claude-code/issues/34804)); mcp-stdio inherits proxy settings from httpx
140
141
 
141
142
  ## Features
@@ -1,3 +1,3 @@
1
1
  """mcp-stdio: Stdio-to-HTTP relay for MCP servers."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.3.3"
@@ -10,6 +10,8 @@ from typing import Any
10
10
 
11
11
  import httpx
12
12
 
13
+ from mcp_stdio import __version__
14
+
13
15
  MAX_RETRIES = 3
14
16
  RETRY_DELAY = 1 # seconds
15
17
 
@@ -99,6 +101,66 @@ def _post_and_stream(
99
101
  return None
100
102
 
101
103
 
104
+ def _reinitialize(
105
+ client: httpx.Client,
106
+ url: str,
107
+ headers: dict[str, str],
108
+ ) -> str | None:
109
+ """Send an initialize handshake to establish a new MCP session.
110
+
111
+ Used to recover after a session expires (server returns 404 on the
112
+ next request). Performs the full MCP initialize handshake:
113
+
114
+ 1. POST an ``initialize`` request to get a new session ID
115
+ 2. POST a ``notifications/initialized`` notification to signal
116
+ readiness (required by the MCP spec before any other requests)
117
+
118
+ Returns the new session ID on success, or None on failure. The
119
+ initialize response payload is discarded — the caller only needs
120
+ the session ID for subsequent requests.
121
+ """
122
+ initialize_msg = json.dumps(
123
+ {
124
+ "jsonrpc": "2.0",
125
+ "method": "initialize",
126
+ "id": 0,
127
+ "params": {
128
+ "protocolVersion": "2024-11-05",
129
+ "capabilities": {},
130
+ "clientInfo": {"name": "mcp-stdio", "version": __version__},
131
+ },
132
+ }
133
+ )
134
+ try:
135
+ resp = client.post(url, content=initialize_msg, headers=headers)
136
+ except httpx.HTTPError as e:
137
+ log(f"re-initialize request failed: {e}")
138
+ return None
139
+ if resp.status_code != 200:
140
+ log(f"re-initialize returned HTTP {resp.status_code}")
141
+ return None
142
+ new_session_id = resp.headers.get("mcp-session-id")
143
+ if not new_session_id:
144
+ log("re-initialize response missing mcp-session-id header")
145
+ return None
146
+
147
+ # MCP spec: send notifications/initialized before any other requests
148
+ initialized_msg = json.dumps(
149
+ {"jsonrpc": "2.0", "method": "notifications/initialized"}
150
+ )
151
+ initialized_headers = dict(headers)
152
+ initialized_headers["Mcp-Session-Id"] = new_session_id
153
+ try:
154
+ resp = client.post(url, content=initialized_msg, headers=initialized_headers)
155
+ except httpx.HTTPError as e:
156
+ log(f"notifications/initialized failed: {e}")
157
+ return None
158
+ if resp.status_code not in (200, 202):
159
+ log(f"notifications/initialized returned HTTP {resp.status_code}")
160
+ return None
161
+ return new_session_id
162
+
163
+
102
164
  def test_connection(
103
165
  url: str,
104
166
  headers: dict[str, str],
@@ -118,15 +180,13 @@ def test_connection(
118
180
  "params": {
119
181
  "protocolVersion": "2024-11-05",
120
182
  "capabilities": {},
121
- "clientInfo": {"name": "mcp-stdio", "version": "test"},
183
+ "clientInfo": {"name": "mcp-stdio", "version": __version__},
122
184
  },
123
185
  }
124
186
  )
125
187
 
126
188
  client = httpx.Client(
127
- timeout=httpx.Timeout(
128
- connect=timeout_connect, read=timeout_read, write=30, pool=10
129
- )
189
+ timeout=httpx.Timeout(connect=timeout_connect, read=timeout_read, write=30, pool=10)
130
190
  )
131
191
 
132
192
  try:
@@ -169,9 +229,7 @@ def test_connection(
169
229
  tools = "yes" if caps.get("tools") else "no"
170
230
  resources = "yes" if caps.get("resources") else "no"
171
231
  prompts = "yes" if caps.get("prompts") else "no"
172
- log(
173
- f"✓ Capabilities: tools={tools}, resources={resources}, prompts={prompts}"
174
- )
232
+ log(f"✓ Capabilities: tools={tools}, resources={resources}, prompts={prompts}")
175
233
  elif result_data and "error" in result_data:
176
234
  err = result_data["error"]
177
235
  log(f"✗ MCP error: {err.get('message', err)}")
@@ -273,11 +331,18 @@ def run(
273
331
  )
274
332
  continue
275
333
 
276
- # Session expired (404) — reset and retry once
334
+ # Session expired (404) — reset, re-initialize, then retry
277
335
  if result.status_code == 404 and session_id:
278
- log("session expired, resetting and retrying")
336
+ log("session expired, re-initializing and retrying")
279
337
  session_id = None
338
+ new_session_id = _reinitialize(client, url, dict(headers))
339
+ if new_session_id is None:
340
+ log("re-initialize failed, dropping request")
341
+ print(_error_response("session lost", req_id), flush=True)
342
+ continue
343
+ session_id = new_session_id
280
344
  req_headers = dict(headers)
345
+ req_headers["Mcp-Session-Id"] = session_id
281
346
  result = _post_and_stream(client, url, line, req_headers, req_id)
282
347
  if result is None:
283
348
  continue
@@ -83,9 +83,7 @@ class TestPostAndStream:
83
83
  httpx_mock.add_exception(httpx.ConnectError("refused"))
84
84
  client = httpx.Client()
85
85
  with patch("mcp_stdio.relay.time.sleep"):
86
- result = _post_and_stream(
87
- client, "https://example.com/mcp", '{"id":1}', {}, 1
88
- )
86
+ result = _post_and_stream(client, "https://example.com/mcp", '{"id":1}', {}, 1)
89
87
  assert result is None
90
88
 
91
89
  def test_non_200_returns_status(self, httpx_mock):
@@ -120,9 +118,7 @@ class TestRun:
120
118
  text=body,
121
119
  headers={"content-type": "application/json"},
122
120
  )
123
- output = self._run_with_stdin(
124
- httpx_mock, ['{"jsonrpc":"2.0","method":"init","id":1}']
125
- )
121
+ output = self._run_with_stdin(httpx_mock, ['{"jsonrpc":"2.0","method":"init","id":1}'])
126
122
  assert json.loads(output.strip()) == json.loads(body)
127
123
 
128
124
  def test_sse_response(self, httpx_mock):
@@ -131,9 +127,7 @@ class TestRun:
131
127
  text=sse_body,
132
128
  headers={"content-type": "text/event-stream"},
133
129
  )
134
- output = self._run_with_stdin(
135
- httpx_mock, ['{"jsonrpc":"2.0","method":"init","id":1}']
136
- )
130
+ output = self._run_with_stdin(httpx_mock, ['{"jsonrpc":"2.0","method":"init","id":1}'])
137
131
  assert json.loads(output.strip())["id"] == 1
138
132
 
139
133
  def test_empty_lines_skipped(self, httpx_mock):
@@ -173,8 +167,16 @@ class TestRun:
173
167
  req2 = httpx_mock.get_requests()[1]
174
168
  assert req2.headers["mcp-session-id"] == "sess-123"
175
169
 
176
- def test_session_expired_retry(self, httpx_mock):
177
- # First request sets a session ID
170
+ def test_session_expired_triggers_reinitialize_then_retry(self, httpx_mock):
171
+ """Reproduces the 404 -> 400 hang from FastMCP StreamableHTTP.
172
+
173
+ Before the fix, mcp-stdio cleared the session_id on 404 and just
174
+ re-sent the original request — but FastMCP requires an initialize
175
+ handshake on each new session, so the retry came back 400 and the
176
+ caller hung. The fix sends an initialize to establish a new
177
+ session first, then replays the original request with it.
178
+ """
179
+ # Request 1: init — server assigns sess-old
178
180
  httpx_mock.add_response(
179
181
  text='{"jsonrpc":"2.0","result":{},"id":1}',
180
182
  headers={
@@ -182,17 +184,28 @@ class TestRun:
182
184
  "mcp-session-id": "sess-old",
183
185
  },
184
186
  )
185
- # Second request gets 404 (session expired)
187
+ # Request 2: tool call with sess-old — server returns 404 (expired)
186
188
  httpx_mock.add_response(
187
189
  status_code=404,
188
190
  text="",
189
191
  headers={"content-type": "application/json"},
190
192
  )
191
- # Retry after session reset succeeds
193
+ # Request 3: _reinitialize sends a fresh initialize — server assigns sess-new
194
+ httpx_mock.add_response(
195
+ text='{"jsonrpc":"2.0","result":{"protocolVersion":"2024-11-05"},"id":0}',
196
+ headers={
197
+ "content-type": "application/json",
198
+ "mcp-session-id": "sess-new",
199
+ },
200
+ )
201
+ # Request 4: _reinitialize sends notifications/initialized with sess-new
202
+ httpx_mock.add_response(status_code=202, text="")
203
+ # Request 5: original tool call replayed with sess-new — server returns result
192
204
  httpx_mock.add_response(
193
205
  text='{"jsonrpc":"2.0","result":{"ok":true},"id":2}',
194
206
  headers={"content-type": "application/json"},
195
207
  )
208
+
196
209
  output = self._run_with_stdin(
197
210
  httpx_mock,
198
211
  [
@@ -201,19 +214,108 @@ class TestRun:
201
214
  ],
202
215
  )
203
216
  lines = [x for x in output.strip().splitlines() if x]
217
+ # stdout gets the two original responses (init + call); the re-initialize
218
+ # handshake is internal and should not leak to stdout.
204
219
  assert len(lines) == 2
205
- # Verify session was reset: the retry request should NOT have Mcp-Session-Id
220
+ assert json.loads(lines[1])["result"] == {"ok": True}
221
+
206
222
  requests = httpx_mock.get_requests()
207
- assert len(requests) == 3
223
+ assert len(requests) == 5
224
+
225
+ # Request 2 (the call) still carried sess-old before the 404
226
+ assert requests[1].headers.get("mcp-session-id") == "sess-old"
227
+
228
+ # Request 3 is the re-initialize: no session header, body is initialize
208
229
  assert "mcp-session-id" not in requests[2].headers
230
+ init_body = json.loads(requests[2].content)
231
+ assert init_body["method"] == "initialize"
232
+
233
+ # Request 4 is notifications/initialized with sess-new
234
+ assert requests[3].headers.get("mcp-session-id") == "sess-new"
235
+ notif_body = json.loads(requests[3].content)
236
+ assert notif_body["method"] == "notifications/initialized"
237
+ assert "id" not in notif_body # notifications must not carry an id
238
+
239
+ # Request 5 is the replayed tool call, now with sess-new
240
+ assert requests[4].headers.get("mcp-session-id") == "sess-new"
241
+ replay_body = json.loads(requests[4].content)
242
+ assert replay_body["method"] == "call"
243
+ assert replay_body["id"] == 2
244
+
245
+ def test_reinitialize_failure_returns_error(self, httpx_mock):
246
+ """If the post-404 re-initialize fails, we surface a JSON-RPC error
247
+ instead of silently dropping the original request."""
248
+ # Request 1: init
249
+ httpx_mock.add_response(
250
+ text='{"jsonrpc":"2.0","result":{},"id":1}',
251
+ headers={
252
+ "content-type": "application/json",
253
+ "mcp-session-id": "sess-old",
254
+ },
255
+ )
256
+ # Request 2: tool call -> 404
257
+ httpx_mock.add_response(status_code=404, text="")
258
+ # Request 3: re-initialize also fails (server still broken)
259
+ httpx_mock.add_response(status_code=500, text="")
260
+
261
+ output = self._run_with_stdin(
262
+ httpx_mock,
263
+ [
264
+ '{"jsonrpc":"2.0","method":"init","id":1}',
265
+ '{"jsonrpc":"2.0","method":"call","id":2}',
266
+ ],
267
+ )
268
+ lines = [x for x in output.strip().splitlines() if x]
269
+ # First response goes through, second is an error reply (not a hang)
270
+ assert len(lines) == 2
271
+ err = json.loads(lines[1])
272
+ assert err["id"] == 2
273
+ assert err["error"]["code"] == -32000
274
+ assert "session lost" in err["error"]["message"]
275
+
276
+ def test_reinitialize_notifications_initialized_failure_returns_error(
277
+ self, httpx_mock
278
+ ):
279
+ """If the initialize succeeds but the notifications/initialized step
280
+ fails, we treat the whole re-init as failed and surface an error."""
281
+ httpx_mock.add_response(
282
+ text='{"jsonrpc":"2.0","result":{},"id":1}',
283
+ headers={
284
+ "content-type": "application/json",
285
+ "mcp-session-id": "sess-old",
286
+ },
287
+ )
288
+ httpx_mock.add_response(status_code=404, text="")
289
+ # Initialize succeeds — server assigns sess-new
290
+ httpx_mock.add_response(
291
+ text='{"jsonrpc":"2.0","result":{},"id":0}',
292
+ headers={
293
+ "content-type": "application/json",
294
+ "mcp-session-id": "sess-new",
295
+ },
296
+ )
297
+ # notifications/initialized fails
298
+ httpx_mock.add_response(status_code=500, text="")
299
+
300
+ output = self._run_with_stdin(
301
+ httpx_mock,
302
+ [
303
+ '{"jsonrpc":"2.0","method":"init","id":1}',
304
+ '{"jsonrpc":"2.0","method":"call","id":2}',
305
+ ],
306
+ )
307
+ lines = [x for x in output.strip().splitlines() if x]
308
+ assert len(lines) == 2
309
+ err = json.loads(lines[1])
310
+ assert err["id"] == 2
311
+ assert err["error"]["code"] == -32000
312
+ assert "session lost" in err["error"]["message"]
209
313
 
210
314
  def test_request_failure_returns_error(self, httpx_mock):
211
315
  for _ in range(3):
212
316
  httpx_mock.add_exception(httpx.ConnectError("refused"))
213
317
  with patch("mcp_stdio.relay.time.sleep"):
214
- output = self._run_with_stdin(
215
- httpx_mock, ['{"jsonrpc":"2.0","method":"init","id":5}']
216
- )
318
+ output = self._run_with_stdin(httpx_mock, ['{"jsonrpc":"2.0","method":"init","id":5}'])
217
319
  result = json.loads(output.strip())
218
320
  assert result["error"]["code"] == -32000
219
321
  assert result["id"] == 5
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes