ms-tau-sdk 2.0.10.dev50__tar.gz → 2.0.11.dev52__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.
Files changed (86) hide show
  1. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/CHANGELOG.md +20 -0
  2. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/PKG-INFO +1 -1
  3. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/pyproject.toml +1 -1
  4. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/agent_skills/tau_project_customization/SKILL.md +5 -0
  5. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/backend/mcp.py +208 -20
  6. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/errors.py +16 -0
  7. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/runtime/requester.py +90 -4
  8. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/settings.py +7 -0
  9. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/tools/mcp_applications.py +44 -10
  10. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/.gitignore +0 -0
  11. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/README.md +0 -0
  12. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/packages/tau-board/src/ms_tau_board/__init__.py +0 -0
  13. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/packages/tau-board/src/ms_tau_board/app.py +0 -0
  14. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/packages/tau-board/src/ms_tau_board/cli.py +0 -0
  15. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/packages/tau-board/src/ms_tau_board/config.py +0 -0
  16. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/packages/tau-board/src/ms_tau_board/env_file.py +0 -0
  17. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/packages/tau-board/src/ms_tau_board/logs.py +0 -0
  18. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/packages/tau-board/src/ms_tau_board/proxy.py +0 -0
  19. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/packages/tau-board/src/ms_tau_board/state.py +0 -0
  20. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/packages/tau-board/src/ms_tau_board/static/BULMA-LICENSE.txt +0 -0
  21. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/packages/tau-board/src/ms_tau_board/static/app.js +0 -0
  22. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/packages/tau-board/src/ms_tau_board/static/board.css +0 -0
  23. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/packages/tau-board/src/ms_tau_board/static/bulma.min.css +0 -0
  24. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/packages/tau-board/src/ms_tau_board/static/index.html +0 -0
  25. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/__init__.py +0 -0
  26. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/agent_skills/tau_a2a_runtime_adapter/SKILL.md +0 -0
  27. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/agent_skills/tau_local_development/SKILL.md +0 -0
  28. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/agent_skills/tau_repository_integration/SKILL.md +0 -0
  29. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/agent_skills/tau_security_and_access/SKILL.md +0 -0
  30. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/api/__init__.py +0 -0
  31. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/api/a2a.py +0 -0
  32. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/api/chat.py +0 -0
  33. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/api/conversations.py +0 -0
  34. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/api/dependencies.py +0 -0
  35. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/api/health.py +0 -0
  36. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/api/inspection.py +0 -0
  37. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/api/local_chat.py +0 -0
  38. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/api/models.py +0 -0
  39. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/api/request_identity.py +0 -0
  40. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/api/sessions.py +0 -0
  41. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/app.py +0 -0
  42. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/application.py +0 -0
  43. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/backend/__init__.py +0 -0
  44. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/backend/assertions.py +0 -0
  45. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/backend/auth.py +0 -0
  46. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/backend/client.py +0 -0
  47. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/backend/local.py +0 -0
  48. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/backend/models.py +0 -0
  49. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/backend/routes.py +0 -0
  50. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/cli.py +0 -0
  51. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/logging.py +0 -0
  52. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/protocols/__init__.py +0 -0
  53. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/protocols/a2a_failure.py +0 -0
  54. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/protocols/a2a_message.py +0 -0
  55. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/protocols/a2a_roles.py +0 -0
  56. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/protocols/assistant_ui.py +0 -0
  57. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/protocols/chat_history.py +0 -0
  58. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/protocols/strict_json.py +0 -0
  59. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/providers/__init__.py +0 -0
  60. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/providers/definitions.py +0 -0
  61. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/providers/factory.py +0 -0
  62. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/providers/tau_compat.py +0 -0
  63. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/resources/SYSTEM.md +0 -0
  64. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/resources/__init__.py +0 -0
  65. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/resources/loader.py +0 -0
  66. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/resources/prompts/review-code-repository.md +0 -0
  67. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/runtime/__init__.py +0 -0
  68. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/runtime/deployment_health.py +0 -0
  69. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/runtime/events.py +0 -0
  70. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/runtime/extensions.py +0 -0
  71. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/runtime/failures.py +0 -0
  72. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/runtime/live_turns.py +0 -0
  73. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/runtime/manager.py +0 -0
  74. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/runtime/observability.py +0 -0
  75. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/runtime/provenance.py +0 -0
  76. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/runtime/session.py +0 -0
  77. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/runtime/snapshots.py +0 -0
  78. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/runtime/task_context.py +0 -0
  79. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/sessions/__init__.py +0 -0
  80. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/sessions/storage.py +0 -0
  81. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/skills.py +0 -0
  82. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/tools/__init__.py +0 -0
  83. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/tools/mainsequence_mcp.py +0 -0
  84. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/tools/mcp_connection.py +0 -0
  85. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/tools/skill_read.py +0 -0
  86. {ms_tau_sdk-2.0.10.dev50 → ms_tau_sdk-2.0.11.dev52}/src/ms_tau_sdk/tools/task_control.py +0 -0
@@ -1,7 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.11 — 2026-10-11
4
+
5
+ - **Every MCP connection honors per-call response deadlines
6
+ ([#90](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/90), ADR 0022 amendment).**
7
+ `call_tool(..., timeout_seconds=N)` can wait for delayed headers, JSON bodies and SSE data
8
+ beyond the backend HTTP read limit. The finite, positive override replaces the session's
9
+ default response wait without changing shared HTTP settings or another call's deadline.
10
+ Expiration and caller cancellation close that call's local response, keep the connection
11
+ usable, and never replay its remote operation; cancelled queued calls are skipped.
12
+ Initialization/catalog/resource read limits and HTTP connect/write/pool limits remain bounded.
13
+ A standalone platform call has no implicit runtime turn limit. Real HTTP regression tests
14
+ cover these behaviors for platform and generic MCP connections.
15
+
3
16
  ## 2.0.10 — 2026-10-09
4
17
 
18
+ - **Declared applications wait while waking ([#87](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/87)).**
19
+ An access response with `state: waking` and no grant no longer fails immediately as
20
+ `access_unavailable`. Tau follows the platform's retry guidance until access is ready, for up
21
+ to `TAU_MCP_APPLICATION_READY_TIMEOUT_SECONDS` (default `120`) or the remaining turn time.
22
+ Cancellation and ended delegation stop the wait, refusals remain terminal, and a call is sent
23
+ only once access is ready. An exhausted wait reports `readiness_timeout`, distinct from the
24
+ tool's execution timeout. The same readiness handling applies when renewing an application token.
5
25
  - **Application access timeouts no longer become `access_unavailable`.** Platform transport
6
26
  timeouts retain their classification and time limit when application access is obtained or
7
27
  renewed. The tool reports `failure: timeout` with `phase: access`; HTTP 408/504 responses
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ms-tau-sdk
3
- Version: 2.0.10.dev50
3
+ Version: 2.0.11.dev52
4
4
  Summary: Workspace-bound Tau application primitives for Main Sequence projects
5
5
  Project-URL: Changelog, https://github.com/mainsequence-sdk/ms-tau-sdk/blob/development/CHANGELOG.md
6
6
  Project-URL: Documentation, https://github.com/mainsequence-sdk/ms-tau-sdk/tree/development/docs
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ms-tau-sdk"
7
- version = "2.0.10.dev50"
7
+ version = "2.0.11.dev52"
8
8
  description = "Workspace-bound Tau application primitives for Main Sequence projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -91,6 +91,11 @@ the application's tool, not in the Agent. A tool that declares nothing gets
91
91
  `harness_agent.spec.env_vars`, the process environment, or
92
92
  `TauSDKSettings(mcp_tool_timeout_seconds=...)`. No call outlives its turn.
93
93
 
94
+ Before MCP opens, an application's access may be `waking`. Tau waits according to the platform's
95
+ retry guidance for up to `TAU_MCP_APPLICATION_READY_TIMEOUT_SECONDS` (default `120`) or the remaining
96
+ turn time. This startup budget is separate from the tool's execution limit; an expired readiness
97
+ wait reports `readiness_timeout`. Cancellation, ended delegation and terminal refusals stop the wait.
98
+
94
99
  ## Invariants extensions must not replace
95
100
 
96
101
  Extensions may change effective agent capabilities, but they must not replace or bypass:
@@ -4,14 +4,20 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import contextvars
7
- from collections.abc import AsyncGenerator
7
+ import math
8
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable
9
+ from contextlib import asynccontextmanager
8
10
  from dataclasses import dataclass
9
11
  from datetime import timedelta
10
- from typing import Any, Literal, Self
12
+ from typing import Any, Literal, Self, cast
11
13
 
14
+ import anyio
12
15
  import httpx
16
+ from anyio.abc import ObjectSendStream
17
+ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
13
18
  from mcp import ClientSession, types
14
- from mcp.client.streamable_http import streamable_http_client
19
+ from mcp.client.streamable_http import RequestContext, StreamableHTTPTransport
20
+ from mcp.shared.message import SessionMessage
15
21
  from pydantic import AnyUrl
16
22
  from structlog.contextvars import bound_contextvars, get_contextvars
17
23
 
@@ -47,6 +53,145 @@ class _RuntimeCredentialHTTPXAuth(httpx.Auth):
47
53
  yield request
48
54
 
49
55
 
56
+ @dataclass
57
+ class _ToolCall:
58
+ cancel_scope: anyio.CancelScope
59
+ request_id: types.RequestId | None = None
60
+ http_cancel_scope: anyio.CancelScope | None = None
61
+ finished: bool = False
62
+
63
+ def cancel_if_abandoned(self, result: asyncio.Future[Any]) -> None:
64
+ if result.cancelled():
65
+ self.cancel_scope.cancel()
66
+
67
+ def finish(self) -> None:
68
+ self.finished = True
69
+ if self.http_cancel_scope is not None:
70
+ self.http_cancel_scope.cancel()
71
+
72
+
73
+ _tool_call: contextvars.ContextVar[_ToolCall | None] = contextvars.ContextVar(
74
+ "mcp_tool_call", default=None
75
+ )
76
+
77
+
78
+ class _ToolCallWriteStream(ObjectSendStream[SessionMessage]):
79
+ """Associate the session's request ID with its call before the transport sends it.
80
+
81
+ MCP's HTTP writer runs in another task, so the calling task's context does not reach its
82
+ requests. Keep this association local to the connection, without adding anything to the wire.
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ stream: MemoryObjectSendStream[SessionMessage],
88
+ calls: dict[types.RequestId, _ToolCall],
89
+ ) -> None:
90
+ self._stream = stream
91
+ self._calls = calls
92
+
93
+ async def send(self, item: SessionMessage) -> None:
94
+ message = item.message.root
95
+ call = _tool_call.get()
96
+ if (
97
+ call is not None
98
+ and isinstance(message, types.JSONRPCRequest)
99
+ and message.method == "tools/call"
100
+ ):
101
+ call.request_id = message.id
102
+ self._calls[message.id] = call
103
+ await self._stream.send(item)
104
+
105
+ async def aclose(self) -> None:
106
+ await self._stream.aclose()
107
+
108
+
109
+ class _ToolCallTransport(StreamableHTTPTransport):
110
+ """Give each call's POST a cancellation scope inside MCP's transport task.
111
+
112
+ Cancelling that task directly cancels MCP's whole task group. A local scope releases the
113
+ response socket when its call ends while leaving other requests and the connection alive.
114
+ """
115
+
116
+ def __init__(self, url: str, calls: dict[types.RequestId, _ToolCall]) -> None:
117
+ super().__init__(url)
118
+ self._calls = calls
119
+
120
+ async def _handle_post_request(self, ctx: RequestContext) -> None:
121
+ message = ctx.session_message.message.root
122
+ call = (
123
+ self._calls.get(message.id)
124
+ if isinstance(message, types.JSONRPCRequest) and message.method == "tools/call"
125
+ else None
126
+ )
127
+ if call is None:
128
+ await super()._handle_post_request(ctx)
129
+ return
130
+
131
+ try:
132
+ with anyio.CancelScope() as http_cancel_scope:
133
+ call.http_cancel_scope = http_cancel_scope
134
+ if call.finished:
135
+ http_cancel_scope.cancel()
136
+ await super()._handle_post_request(ctx)
137
+ finally:
138
+ if call.request_id is not None:
139
+ self._calls.pop(call.request_id, None)
140
+
141
+
142
+ @asynccontextmanager
143
+ async def streamable_http_client(
144
+ url: str,
145
+ *,
146
+ http_client: httpx.AsyncClient,
147
+ calls: dict[types.RequestId, _ToolCall],
148
+ terminate_on_close: bool = False,
149
+ ) -> AsyncIterator[
150
+ tuple[
151
+ MemoryObjectReceiveStream[SessionMessage | Exception],
152
+ MemoryObjectSendStream[SessionMessage],
153
+ Callable[[], str | None],
154
+ ]
155
+ ]:
156
+ """Use MCP's HTTP readers/writer with call-scoped POST cancellation.
157
+
158
+ The upstream factory constructs its transport internally without an injection point.
159
+ Preserve its stream/task-group lifecycle while selecting our transport specialization.
160
+ """
161
+ read_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](0)
162
+ write_stream, write_reader = anyio.create_memory_object_stream[SessionMessage](0)
163
+ transport = _ToolCallTransport(url, calls)
164
+ async with anyio.create_task_group() as task_group:
165
+ try:
166
+
167
+ def start_get_stream() -> None:
168
+ task_group.start_soon(transport.handle_get_stream, http_client, read_writer)
169
+
170
+ task_group.start_soon(
171
+ transport.post_writer,
172
+ http_client,
173
+ write_reader,
174
+ read_writer,
175
+ write_stream,
176
+ start_get_stream,
177
+ task_group,
178
+ )
179
+ try:
180
+ yield read_stream, write_stream, transport.get_session_id
181
+ finally:
182
+ if terminate_on_close:
183
+ await transport.terminate_session(http_client)
184
+ task_group.cancel_scope.cancel()
185
+ finally:
186
+ await read_writer.aclose()
187
+ await write_stream.aclose()
188
+
189
+
190
+ def _validate_response_timeout(seconds: float) -> None:
191
+ if isinstance(seconds, bool) or not math.isfinite(seconds) or seconds <= 0:
192
+ raise ValueError("MCP response timeout must be finite and greater than zero")
193
+
194
+
50
195
  @dataclass(frozen=True)
51
196
  class _CallToolCommand:
52
197
  name: str
@@ -101,6 +246,7 @@ class MCPConnectionClient:
101
246
  read_timeout_seconds: float,
102
247
  read_concurrency: int,
103
248
  ) -> None:
249
+ _validate_response_timeout(read_timeout_seconds)
104
250
  self.name = name
105
251
  self.display_name = display_name
106
252
  self.url = url
@@ -115,6 +261,7 @@ class MCPConnectionClient:
115
261
  # The results of the calls already handed to the session, so that a failed connection
116
262
  # ends them as well as the calls still queued.
117
263
  self._in_flight: set[asyncio.Future[Any]] = set()
264
+ self._http_calls: dict[types.RequestId, _ToolCall] = {}
118
265
  self.tools: tuple[types.Tool, ...] = ()
119
266
  self.resources: tuple[types.Resource, ...] = ()
120
267
  self._parallel_tool_names: frozenset[str] = frozenset()
@@ -156,15 +303,21 @@ class MCPConnectionClient:
156
303
  async with httpx.AsyncClient(
157
304
  timeout=self._http_timeout,
158
305
  auth=self._http_auth,
306
+ event_hooks={"request": [self._prepare_tool_request]},
159
307
  ) as http_client:
160
308
  async with streamable_http_client(
161
309
  self.url,
162
310
  http_client=http_client,
311
+ calls=self._http_calls,
163
312
  terminate_on_close=False,
164
313
  ) as (read_stream, write_stream, _get_session_id):
165
314
  async with ClientSession(
166
315
  read_stream,
167
- write_stream,
316
+ # MCP annotates this as a memory stream, but uses only send/aclose.
317
+ cast(
318
+ MemoryObjectSendStream[SessionMessage],
319
+ _ToolCallWriteStream(write_stream, self._http_calls),
320
+ ),
168
321
  read_timeout_seconds=timedelta(seconds=self._read_timeout_seconds),
169
322
  ) as session:
170
323
  with bound_contextvars(**initial_log_context):
@@ -194,6 +347,20 @@ class MCPConnectionClient:
194
347
  self._fail_pending_commands(
195
348
  failure or RuntimeError(f"{self.display_name} MCP client closed")
196
349
  )
350
+ self._http_calls.clear()
351
+
352
+ async def _prepare_tool_request(self, request: httpx.Request) -> None:
353
+ if request.method != "POST":
354
+ return
355
+ message = types.JSONRPCMessage.model_validate_json(request.content).root
356
+ if (
357
+ isinstance(message, types.JSONRPCRequest)
358
+ and message.method == "tools/call"
359
+ and message.id in self._http_calls
360
+ ):
361
+ # Only this response read follows the finite call deadline. Keep the request's
362
+ # connect/write/pool limits, without mutating the shared HTTP client defaults.
363
+ request.extensions["timeout"] = {**request.extensions["timeout"], "read": None}
197
364
 
198
365
  async def _read_server_catalog(
199
366
  self,
@@ -219,25 +386,38 @@ class MCPConnectionClient:
219
386
  read_slots = asyncio.Semaphore(self._read_concurrency)
220
387
 
221
388
  async def execute(command: _CallToolCommand | _ReadResourceCommand) -> None:
389
+ if command.result.cancelled():
390
+ return
222
391
  self._in_flight.add(command.result)
223
392
  try:
224
393
  with bound_contextvars(**command.log_context):
225
394
  if isinstance(command, _CallToolCommand):
226
- if command.timeout_seconds is None:
227
- tool_result = await session.call_tool(
228
- command.name,
229
- command.arguments,
230
- meta=command.meta,
231
- )
232
- else:
233
- tool_result = await session.call_tool(
234
- command.name,
235
- command.arguments,
236
- read_timeout_seconds=timedelta(seconds=command.timeout_seconds),
237
- meta=command.meta,
238
- )
239
- if not command.result.done():
240
- command.result.set_result(tool_result)
395
+ with anyio.CancelScope() as cancel_scope:
396
+ call = _ToolCall(cancel_scope)
397
+ token = _tool_call.set(call)
398
+ command.result.add_done_callback(call.cancel_if_abandoned)
399
+ try:
400
+ if command.timeout_seconds is None:
401
+ tool_result = await session.call_tool(
402
+ command.name,
403
+ command.arguments,
404
+ meta=command.meta,
405
+ )
406
+ else:
407
+ tool_result = await session.call_tool(
408
+ command.name,
409
+ command.arguments,
410
+ read_timeout_seconds=timedelta(
411
+ seconds=command.timeout_seconds
412
+ ),
413
+ meta=command.meta,
414
+ )
415
+ if not command.result.done():
416
+ command.result.set_result(tool_result)
417
+ finally:
418
+ command.result.remove_done_callback(call.cancel_if_abandoned)
419
+ _tool_call.reset(token)
420
+ call.finish()
241
421
  else:
242
422
  resource_result = await session.read_resource(AnyUrl(command.uri))
243
423
  if not command.result.done():
@@ -313,8 +493,16 @@ class MCPConnectionClient:
313
493
  meta: dict[str, object] | None = None,
314
494
  timeout_seconds: float | None = None,
315
495
  ) -> types.CallToolResult:
316
- """Call one tool; ``timeout_seconds`` replaces the session's wait for its answer."""
496
+ """Wait for one tool's answer within its finite, positive per-call response deadline.
497
+
498
+ The deadline starts when the queued call reaches the MCP session and replaces the
499
+ session's default response wait. HTTP read inactivity does not shorten it; connect,
500
+ write and pool limits still apply. Timeout or cancellation never replays the call and
501
+ does not establish whether its remote work completed.
502
+ """
317
503
 
504
+ if timeout_seconds is not None:
505
+ _validate_response_timeout(timeout_seconds)
318
506
  commands = self._require_commands()
319
507
  result: asyncio.Future[types.CallToolResult] = asyncio.get_running_loop().create_future()
320
508
  commands.put_nowait(
@@ -45,6 +45,22 @@ class BackendTimeoutError(BackendError):
45
45
  self.timeout_seconds = timeout_seconds
46
46
 
47
47
 
48
+ class ApplicationWakingError(BackendError):
49
+ """The platform accepted access resolution but the application is still starting."""
50
+
51
+ def __init__(self, *, retry_after_seconds: float) -> None:
52
+ super().__init__("Application is waking", detail={"runtime_access_state": "waking"})
53
+ self.retry_after_seconds = retry_after_seconds
54
+
55
+
56
+ class ApplicationReadinessTimeoutError(BackendError):
57
+ """An application's bounded readiness wait ended before access became ready."""
58
+
59
+ def __init__(self, *, timeout_seconds: float) -> None:
60
+ super().__init__("Application did not become ready")
61
+ self.timeout_seconds = timeout_seconds
62
+
63
+
48
64
  class LeaseLostError(TauSDKError):
49
65
  code = "runtime_lease_lost"
50
66
  status_code = 409
@@ -16,6 +16,7 @@ credential or an application token to the tool, and a turn's delegation ends wit
16
16
  from __future__ import annotations
17
17
 
18
18
  import asyncio
19
+ import math
19
20
  import time
20
21
  import uuid
21
22
  from collections.abc import Awaitable, Callable, Mapping
@@ -30,7 +31,13 @@ import structlog
30
31
  from ms_tau_sdk.backend.auth import AccessToken, _jwt_expiry
31
32
  from ms_tau_sdk.backend.client import LeaseProof, answer_without_request_credentials
32
33
  from ms_tau_sdk.backend.models import ReleaseRuntimeAccess
33
- from ms_tau_sdk.errors import BackendError, BackendTimeoutError, TauSDKError
34
+ from ms_tau_sdk.errors import (
35
+ ApplicationReadinessTimeoutError,
36
+ ApplicationWakingError,
37
+ BackendError,
38
+ BackendTimeoutError,
39
+ TauSDKError,
40
+ )
34
41
 
35
42
  logger = structlog.get_logger(__name__)
36
43
 
@@ -569,7 +576,12 @@ def _current_turn_binding() -> TurnRequesterBinding | None:
569
576
  return _TURN_BINDING.get()
570
577
 
571
578
 
572
- async def _turn_application_access(release_uid: str) -> _TurnApplicationAccess:
579
+ async def _turn_application_access(
580
+ release_uid: str,
581
+ *,
582
+ ready_timeout_seconds: float = 120,
583
+ cancelled: Callable[[], bool] | None = None,
584
+ ) -> _TurnApplicationAccess:
573
585
  """SDK-private: the current turn's access to one application.
574
586
 
575
587
  The token acts for the person the turn serves, when it serves one, and is the Agent's own
@@ -579,12 +591,75 @@ async def _turn_application_access(release_uid: str) -> _TurnApplicationAccess:
579
591
  binding = _TURN_BINDING.get()
580
592
  if binding is None:
581
593
  raise RuntimeError("Application tools run only inside a turn")
594
+ if not binding.active:
595
+ raise RequesterBindingError("The turn ended while obtaining application access.")
582
596
  uid = _canonical_uid(release_uid)
583
597
  delegate = binding.serving
584
- access = await binding._release_access_for(uid, refresh=False, delegate=delegate)
598
+
599
+ async def resolve(*, refresh: bool) -> _ReleaseAccess:
600
+ # Capture delegation once, including for renewal; an ended turn never falls back to the
601
+ # Agent's own access. Each poll reacquires the binding lock and current lease proof.
602
+ started = asyncio.get_running_loop().time()
603
+ deadline = asyncio.timeout(None)
604
+ waiting = False
605
+ try:
606
+ async with deadline:
607
+ while True:
608
+ if not binding.active:
609
+ raise RequesterBindingError(
610
+ "The turn ended while obtaining application access."
611
+ )
612
+ try:
613
+ access = await binding._release_access_for(
614
+ uid, refresh=refresh, delegate=delegate
615
+ )
616
+ except ApplicationWakingError as error:
617
+ if not waiting:
618
+ waiting = True
619
+ deadline.reschedule(started + ready_timeout_seconds)
620
+ logger.info(
621
+ "runtime.application_access.waiting",
622
+ resource_release_uid=uid,
623
+ runtime_access_state="waking",
624
+ retry_after_seconds=error.retry_after_seconds,
625
+ )
626
+ await asyncio.sleep(error.retry_after_seconds)
627
+ else:
628
+ if waiting:
629
+ logger.info(
630
+ "runtime.application_access.ready", resource_release_uid=uid
631
+ )
632
+ return access
633
+ except TimeoutError:
634
+ if not deadline.expired():
635
+ raise
636
+ # No transport error or credential-bearing exception is retained as the cause.
637
+ raise ApplicationReadinessTimeoutError(timeout_seconds=ready_timeout_seconds)
638
+
639
+ async def obtain(*, refresh: bool) -> _ReleaseAccess:
640
+ # ToolCancellationToken exposes only is_cancelled(). Poll it while both the readiness
641
+ # delay and the platform request are pending, and join cancelled work before returning.
642
+ task = asyncio.create_task(resolve(refresh=refresh))
643
+ try:
644
+ while True:
645
+ if not binding.active:
646
+ raise RequesterBindingError(
647
+ "The turn ended while obtaining application access."
648
+ )
649
+ if cancelled is not None and cancelled():
650
+ raise asyncio.CancelledError
651
+ if task.done():
652
+ return task.result()
653
+ await asyncio.wait({task}, timeout=0.1)
654
+ finally:
655
+ if not task.done():
656
+ task.cancel()
657
+ await asyncio.gather(task, return_exceptions=True)
658
+
659
+ access = await obtain(refresh=False)
585
660
 
586
661
  async def renew() -> str:
587
- return (await binding._release_access_for(uid, refresh=True, delegate=delegate)).token.value
662
+ return (await obtain(refresh=True)).token.value
588
663
 
589
664
  return _TurnApplicationAccess(rpc_url=access.rpc_url, token=access.token.value, renew=renew)
590
665
 
@@ -660,6 +735,17 @@ def _token_access(answer: ReleaseRuntimeAccess) -> _ReleaseAccess:
660
735
  grant = answer.access
661
736
  if grant is None:
662
737
  state = answer.runtime_access.get("state")
738
+ if state == "waking":
739
+ retry_ms = answer.runtime_access.get("retry_after_ms")
740
+ delay = 2.0
741
+ if isinstance(retry_ms, int | float) and not isinstance(retry_ms, bool):
742
+ try:
743
+ seconds = float(retry_ms) / 1000
744
+ except OverflowError:
745
+ seconds = float("inf")
746
+ if math.isfinite(seconds) and seconds >= 0:
747
+ delay = max(seconds, 0.1)
748
+ raise ApplicationWakingError(retry_after_seconds=delay)
663
749
  raise BackendError(
664
750
  f"Release {answer.resource_release_uid} is not ready to be called"
665
751
  + (f" (runtime access: {state})" if isinstance(state, str) else ""),
@@ -220,6 +220,7 @@ class TauSDKSettings(BaseSettings):
220
220
  backend_read_timeout_seconds: float = Field(
221
221
  default=60.0,
222
222
  gt=0,
223
+ allow_inf_nan=False,
223
224
  validation_alias="MAINSEQUENCE_TAU_BACKEND_READ_TIMEOUT_SECONDS",
224
225
  )
225
226
  backend_write_timeout_seconds: float = Field(
@@ -248,6 +249,12 @@ class TauSDKSettings(BaseSettings):
248
249
  gt=0,
249
250
  validation_alias="TAU_MCP_TOOL_TIMEOUT_SECONDS",
250
251
  )
252
+ mcp_application_ready_timeout_seconds: float = Field(
253
+ default=120.0,
254
+ gt=0,
255
+ allow_inf_nan=False,
256
+ validation_alias="TAU_MCP_APPLICATION_READY_TIMEOUT_SECONDS",
257
+ )
251
258
  backend_max_response_bytes: int = Field(
252
259
  default=10 * 1024 * 1024,
253
260
  gt=0,
@@ -19,7 +19,8 @@ from __future__ import annotations
19
19
  import json
20
20
  import math
21
21
  import re
22
- from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence
22
+ from collections.abc import AsyncGenerator, Mapping, Sequence
23
+ from typing import Protocol
23
24
 
24
25
  import httpx
25
26
  import structlog
@@ -35,7 +36,13 @@ from tau_agent.types import JSONValue
35
36
 
36
37
  from ms_tau_sdk.backend.mcp import MCPConnectionClient
37
38
  from ms_tau_sdk.backend.models import MCPApplication
38
- from ms_tau_sdk.errors import BackendError, BackendTimeoutError, ConfigurationError
39
+ from ms_tau_sdk.errors import (
40
+ ApplicationReadinessTimeoutError,
41
+ ApplicationWakingError,
42
+ BackendError,
43
+ BackendTimeoutError,
44
+ ConfigurationError,
45
+ )
39
46
  from ms_tau_sdk.runtime.requester import (
40
47
  RequesterBindingError,
41
48
  _turn_application_access,
@@ -52,7 +59,11 @@ _RESERVED_NAMES = frozenset({"mainsequence"})
52
59
  TIMEOUT_META_KEY = "mainsequence.ai/timeout-seconds/v1"
53
60
  """The ``_meta`` key under which an application tool declares how long it may run, in seconds."""
54
61
 
55
- type ApplicationConnector = Callable[[MCPApplication], Awaitable[MCPConnectionClient]]
62
+
63
+ class ApplicationConnector(Protocol):
64
+ async def __call__(
65
+ self, application: MCPApplication, *, signal: ToolCancellationToken | None = None
66
+ ) -> MCPConnectionClient: ...
56
67
 
57
68
 
58
69
  class _ApplicationTokenAuth(httpx.Auth):
@@ -77,18 +88,24 @@ class _ApplicationTokenAuth(httpx.Auth):
77
88
  def application_connector(settings: TauSDKSettings) -> ApplicationConnector:
78
89
  """Open an MCP session to one application for the current turn."""
79
90
 
80
- async def connect(application: MCPApplication) -> MCPConnectionClient:
81
- access = await _turn_application_access(application.resource_release_uid)
91
+ async def connect(
92
+ application: MCPApplication, *, signal: ToolCancellationToken | None = None
93
+ ) -> MCPConnectionClient:
94
+ access = await _turn_application_access(
95
+ application.resource_release_uid,
96
+ ready_timeout_seconds=settings.mcp_application_ready_timeout_seconds,
97
+ cancelled=signal.is_cancelled if signal is not None else None,
98
+ )
82
99
  return await MCPConnectionClient(
83
100
  name=application.name,
84
101
  display_name=application.name,
85
102
  url=f"{str(access.rpc_url).rstrip('/')}/mcp",
86
103
  auth=_ApplicationTokenAuth(access),
87
- # The session's wait bounds each request: the backend read timeout while the session
88
- # opens, the tool's own limit for the call. The HTTP read wait never cuts a call short.
104
+ # The connection applies the tool's response deadline to its call; these HTTP limits
105
+ # also bound initialization and catalog requests.
89
106
  timeout=httpx.Timeout(
90
107
  connect=settings.backend_connect_timeout_seconds,
91
- read=settings.turn_timeout_seconds,
108
+ read=settings.backend_read_timeout_seconds,
92
109
  write=settings.backend_write_timeout_seconds,
93
110
  pool=settings.backend_pool_timeout_seconds,
94
111
  ),
@@ -118,6 +135,23 @@ def _failure(
118
135
 
119
136
  if isinstance(error, RequesterBindingError):
120
137
  return "Your access for this request ended.", {"failure": "access_ended"}
138
+ if isinstance(error, ApplicationReadinessTimeoutError):
139
+ return (
140
+ f"The {name} application did not become ready within "
141
+ f"{error.timeout_seconds:g} seconds. "
142
+ "It is still starting; try again shortly.",
143
+ {
144
+ "failure": "readiness_timeout",
145
+ "phase": "readiness",
146
+ "runtime_access_state": "waking",
147
+ "timeout_seconds": error.timeout_seconds,
148
+ },
149
+ )
150
+ if isinstance(error, ApplicationWakingError):
151
+ return (
152
+ f"The {name} application is still starting; try again shortly.",
153
+ {"failure": "not_ready", "phase": "readiness", "runtime_access_state": "waking"},
154
+ )
121
155
  if isinstance(error, BackendError):
122
156
  if isinstance(error, BackendTimeoutError) or error.backend_status in {408, 504}:
123
157
  details: dict[str, JSONValue] = {"failure": "timeout", "phase": "access"}
@@ -217,7 +251,7 @@ def _application_tools(
217
251
  async def open_session(signal: ToolCancellationToken | None) -> MCPConnectionClient:
218
252
  # Nothing has reached a tool while the session opens, so one more attempt is safe.
219
253
  try:
220
- return await connect(application)
254
+ return await connect(application, signal=signal)
221
255
  except Exception as error:
222
256
  if not _retryable(error) or (signal is not None and signal.is_cancelled()):
223
257
  raise
@@ -226,7 +260,7 @@ def _application_tools(
226
260
  application=name,
227
261
  error_type=type(error).__name__,
228
262
  )
229
- return await connect(application)
263
+ return await connect(application, signal=signal)
230
264
 
231
265
  def failed(
232
266
  operation: str,