ms-tau-sdk 2.0.9.dev48__tar.gz → 2.0.10.dev50__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.9.dev48 → ms_tau_sdk-2.0.10.dev50}/CHANGELOG.md +27 -1
  2. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/PKG-INFO +1 -1
  3. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/pyproject.toml +1 -1
  4. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/agent_skills/tau_project_customization/SKILL.md +10 -0
  5. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/backend/client.py +15 -0
  6. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/backend/mcp.py +32 -10
  7. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/errors.py +8 -0
  8. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/runtime/manager.py +2 -0
  9. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/runtime/requester.py +7 -3
  10. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/settings.py +6 -0
  11. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/tools/mcp_applications.py +179 -29
  12. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/.gitignore +0 -0
  13. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/README.md +0 -0
  14. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/packages/tau-board/src/ms_tau_board/__init__.py +0 -0
  15. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/packages/tau-board/src/ms_tau_board/app.py +0 -0
  16. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/packages/tau-board/src/ms_tau_board/cli.py +0 -0
  17. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/packages/tau-board/src/ms_tau_board/config.py +0 -0
  18. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/packages/tau-board/src/ms_tau_board/env_file.py +0 -0
  19. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/packages/tau-board/src/ms_tau_board/logs.py +0 -0
  20. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/packages/tau-board/src/ms_tau_board/proxy.py +0 -0
  21. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/packages/tau-board/src/ms_tau_board/state.py +0 -0
  22. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/packages/tau-board/src/ms_tau_board/static/BULMA-LICENSE.txt +0 -0
  23. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/packages/tau-board/src/ms_tau_board/static/app.js +0 -0
  24. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/packages/tau-board/src/ms_tau_board/static/board.css +0 -0
  25. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/packages/tau-board/src/ms_tau_board/static/bulma.min.css +0 -0
  26. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/packages/tau-board/src/ms_tau_board/static/index.html +0 -0
  27. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/__init__.py +0 -0
  28. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/agent_skills/tau_a2a_runtime_adapter/SKILL.md +0 -0
  29. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/agent_skills/tau_local_development/SKILL.md +0 -0
  30. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/agent_skills/tau_repository_integration/SKILL.md +0 -0
  31. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/agent_skills/tau_security_and_access/SKILL.md +0 -0
  32. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/api/__init__.py +0 -0
  33. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/api/a2a.py +0 -0
  34. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/api/chat.py +0 -0
  35. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/api/conversations.py +0 -0
  36. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/api/dependencies.py +0 -0
  37. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/api/health.py +0 -0
  38. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/api/inspection.py +0 -0
  39. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/api/local_chat.py +0 -0
  40. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/api/models.py +0 -0
  41. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/api/request_identity.py +0 -0
  42. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/api/sessions.py +0 -0
  43. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/app.py +0 -0
  44. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/application.py +0 -0
  45. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/backend/__init__.py +0 -0
  46. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/backend/assertions.py +0 -0
  47. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/backend/auth.py +0 -0
  48. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/backend/local.py +0 -0
  49. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/backend/models.py +0 -0
  50. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/backend/routes.py +0 -0
  51. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/cli.py +0 -0
  52. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/logging.py +0 -0
  53. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/protocols/__init__.py +0 -0
  54. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/protocols/a2a_failure.py +0 -0
  55. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/protocols/a2a_message.py +0 -0
  56. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/protocols/a2a_roles.py +0 -0
  57. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/protocols/assistant_ui.py +0 -0
  58. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/protocols/chat_history.py +0 -0
  59. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/protocols/strict_json.py +0 -0
  60. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/providers/__init__.py +0 -0
  61. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/providers/definitions.py +0 -0
  62. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/providers/factory.py +0 -0
  63. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/providers/tau_compat.py +0 -0
  64. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/resources/SYSTEM.md +0 -0
  65. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/resources/__init__.py +0 -0
  66. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/resources/loader.py +0 -0
  67. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/resources/prompts/review-code-repository.md +0 -0
  68. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/runtime/__init__.py +0 -0
  69. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/runtime/deployment_health.py +0 -0
  70. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/runtime/events.py +0 -0
  71. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/runtime/extensions.py +0 -0
  72. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/runtime/failures.py +0 -0
  73. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/runtime/live_turns.py +0 -0
  74. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/runtime/observability.py +0 -0
  75. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/runtime/provenance.py +0 -0
  76. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/runtime/session.py +0 -0
  77. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/runtime/snapshots.py +0 -0
  78. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/runtime/task_context.py +0 -0
  79. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/sessions/__init__.py +0 -0
  80. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/sessions/storage.py +0 -0
  81. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/skills.py +0 -0
  82. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/tools/__init__.py +0 -0
  83. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/tools/mainsequence_mcp.py +0 -0
  84. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/tools/mcp_connection.py +0 -0
  85. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/tools/skill_read.py +0 -0
  86. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.10.dev50}/src/ms_tau_sdk/tools/task_control.py +0 -0
@@ -1,6 +1,14 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 2.0.10 — 2026-10-09
4
+
5
+ - **Application access timeouts no longer become `access_unavailable`.** Platform transport
6
+ timeouts retain their classification and time limit when application access is obtained or
7
+ renewed. The tool reports `failure: timeout` with `phase: access`; HTTP 408/504 responses
8
+ also report a timeout with their status, without claiming the tool's configured limit elapsed.
9
+ Other access failures retain their classification. Tool calls are never replayed after failure.
10
+
11
+ ## 2.0.9 — 2026-10-09
4
12
 
5
13
  - **Turn logs name the session's Agent
6
14
  ([#85](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/85)).** Every log line of a turn
@@ -9,6 +17,24 @@
9
17
  the session loads, or, before the session has loaded, the one its Agent card names (one extra
10
18
  request, only then). Local turns carry the workspace's local Agent,
11
19
  `local-agent-<workspace digest>`.
20
+ - **Application tool calls get their own time limits, and failures say what happened
21
+ ([#84](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/84), ADR 0022).**
22
+ - A call that met an error status from the application or its gateway, such as 403, 502 or 503,
23
+ waited until the turn's 15-minute limit. It now ends at once with that status. The Main
24
+ Sequence MCP connection had the same defect and is fixed too.
25
+ - Each application tool declares how long it may run in its MCP `_meta` under
26
+ `mainsequence.ai/timeout-seconds/v1`, for example
27
+ `@mcp.tool(meta={"mainsequence.ai/timeout-seconds/v1": 300})`. A tool that declares nothing
28
+ gets the new `TAU_MCP_TOOL_TIMEOUT_SECONDS` (default `60`).
29
+ `MAINSEQUENCE_TAU_BACKEND_READ_TIMEOUT_SECONDS` no longer limits the call itself. To find the
30
+ limit, `<name>__call_tool` now reads the application's tool list before each call.
31
+ - The model reads what failed, for example "The orders tool rebuild_report did not answer
32
+ within 300 seconds." or "The orders application answered with a server error (502).", instead
33
+ of "could not be reached" for every failure. Details and the
34
+ `runtime.mcp_application.failed` log event carry `failure`, `status` and `timeout_seconds`.
35
+ - Opening the application's session is tried once more when it fails on the connection, a
36
+ time-out or a server error. A call that was sent is never sent again.
37
+ - See [Application tool time limits](docs/guides/application-tool-time-limits.md).
12
38
 
13
39
  ## 2.0.8 — 2026-10-08
14
40
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ms-tau-sdk
3
- Version: 2.0.9.dev48
3
+ Version: 2.0.10.dev50
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.9.dev48"
7
+ version = "2.0.10.dev50"
8
8
  description = "Workspace-bound Tau application primitives for Main Sequence projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -81,6 +81,16 @@ in the system prompt because a tool named `read` exists. Extensions must not reg
81
81
  this mode.
82
82
  Excluding coding tools does not sandbox extension Python code.
83
83
 
84
+ ## Application tool time limits
85
+
86
+ A declared application's tool sets how long it may run, in seconds, in its MCP `_meta` under
87
+ `mainsequence.ai/timeout-seconds/v1`; with the MCP Python SDK that is
88
+ `@mcp.tool(meta={"mainsequence.ai/timeout-seconds/v1": 300})` in the application. Set the limit on
89
+ the application's tool, not in the Agent. A tool that declares nothing gets
90
+ `TAU_MCP_TOOL_TIMEOUT_SECONDS` (default `60`); set it like the tool composition settings above, in
91
+ `harness_agent.spec.env_vars`, the process environment, or
92
+ `TauSDKSettings(mcp_tool_timeout_seconds=...)`. No call outlives its turn.
93
+
84
94
  ## Invariants extensions must not replace
85
95
 
86
96
  Extensions may change effective agent capabilities, but they must not replace or bypass:
@@ -19,6 +19,7 @@ from tau_coding.oauth import account_id_from_access_token
19
19
  from ms_tau_sdk.errors import (
20
20
  BackendConflictError,
21
21
  BackendError,
22
+ BackendTimeoutError,
22
23
  LocalModeUnsupportedError,
23
24
  SessionNotFoundError,
24
25
  )
@@ -372,6 +373,20 @@ class MainSequenceClient:
372
373
  if transport_attempt >= attempts:
373
374
  break
374
375
  await asyncio.sleep(0.25 * (2 ** (transport_attempt - 1)))
376
+ if isinstance(last_error, httpx.TimeoutException):
377
+ timeout_phase = (
378
+ "connect"
379
+ if isinstance(last_error, httpx.ConnectTimeout)
380
+ else "write"
381
+ if isinstance(last_error, httpx.WriteTimeout)
382
+ else "pool"
383
+ if isinstance(last_error, httpx.PoolTimeout)
384
+ else "read"
385
+ )
386
+ raise BackendTimeoutError(
387
+ "Backend request timed out",
388
+ timeout_seconds=getattr(self._client.timeout, timeout_phase),
389
+ ) from last_error
375
390
  raise BackendError(f"Backend transport failed: {last_error}") from last_error
376
391
 
377
392
  @staticmethod
@@ -7,7 +7,7 @@ import contextvars
7
7
  from collections.abc import AsyncGenerator
8
8
  from dataclasses import dataclass
9
9
  from datetime import timedelta
10
- from typing import Literal, Self
10
+ from typing import Any, Literal, Self
11
11
 
12
12
  import httpx
13
13
  from mcp import ClientSession, types
@@ -52,6 +52,7 @@ class _CallToolCommand:
52
52
  name: str
53
53
  arguments: dict[str, object]
54
54
  meta: dict[str, object] | None
55
+ timeout_seconds: float | None
55
56
  log_context: dict[str, object]
56
57
  result: asyncio.Future[types.CallToolResult]
57
58
 
@@ -99,10 +100,8 @@ class MCPConnectionClient:
99
100
  timeout: httpx.Timeout,
100
101
  read_timeout_seconds: float,
101
102
  read_concurrency: int,
102
- read_catalog: bool = True,
103
103
  ) -> None:
104
104
  self.name = name
105
- self._read_catalog = read_catalog
106
105
  self.display_name = display_name
107
106
  self.url = url
108
107
  self._http_auth = auth
@@ -113,6 +112,9 @@ class MCPConnectionClient:
113
112
  self._owner_task: asyncio.Task[None] | None = None
114
113
  self._ready: asyncio.Future[None] | None = None
115
114
  self._failure: Exception | None = None
115
+ # The results of the calls already handed to the session, so that a failed connection
116
+ # ends them as well as the calls still queued.
117
+ self._in_flight: set[asyncio.Future[Any]] = set()
116
118
  self.tools: tuple[types.Tool, ...] = ()
117
119
  self.resources: tuple[types.Resource, ...] = ()
118
120
  self._parallel_tool_names: frozenset[str] = frozenset()
@@ -198,8 +200,6 @@ class MCPConnectionClient:
198
200
  session: ClientSession,
199
201
  initialized: types.InitializeResult | None,
200
202
  ) -> None:
201
- if not self._read_catalog:
202
- return
203
203
  # Read only what the server offers; a server without resources answers "not found".
204
204
  capabilities = initialized.capabilities if initialized is not None else None
205
205
  offers_tools = capabilities is None or capabilities.tools is not None
@@ -219,14 +219,23 @@ class MCPConnectionClient:
219
219
  read_slots = asyncio.Semaphore(self._read_concurrency)
220
220
 
221
221
  async def execute(command: _CallToolCommand | _ReadResourceCommand) -> None:
222
+ self._in_flight.add(command.result)
222
223
  try:
223
224
  with bound_contextvars(**command.log_context):
224
225
  if isinstance(command, _CallToolCommand):
225
- tool_result = await session.call_tool(
226
- command.name,
227
- command.arguments,
228
- meta=command.meta,
229
- )
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
+ )
230
239
  if not command.result.done():
231
240
  command.result.set_result(tool_result)
232
241
  else:
@@ -236,6 +245,11 @@ class MCPConnectionClient:
236
245
  except Exception as error:
237
246
  if not command.result.done():
238
247
  command.result.set_exception(error)
248
+ finally:
249
+ # A transport failure cancels the session's work before its cause is known; the
250
+ # owner task then ends this call with that cause.
251
+ if command.result.done():
252
+ self._in_flight.discard(command.result)
239
253
 
240
254
  async def execute_read(
241
255
  command: _CallToolCommand | _ReadResourceCommand,
@@ -271,6 +285,10 @@ class MCPConnectionClient:
271
285
  await asyncio.gather(*tuple(active_reads), return_exceptions=True)
272
286
 
273
287
  def _fail_pending_commands(self, error: Exception) -> None:
288
+ for result in self._in_flight:
289
+ if not result.done():
290
+ result.set_exception(error)
291
+ self._in_flight.clear()
274
292
  if self._commands is None:
275
293
  return
276
294
  while not self._commands.empty():
@@ -293,7 +311,10 @@ class MCPConnectionClient:
293
311
  arguments: dict[str, object],
294
312
  *,
295
313
  meta: dict[str, object] | None = None,
314
+ timeout_seconds: float | None = None,
296
315
  ) -> types.CallToolResult:
316
+ """Call one tool; ``timeout_seconds`` replaces the session's wait for its answer."""
317
+
297
318
  commands = self._require_commands()
298
319
  result: asyncio.Future[types.CallToolResult] = asyncio.get_running_loop().create_future()
299
320
  commands.put_nowait(
@@ -301,6 +322,7 @@ class MCPConnectionClient:
301
322
  name=name,
302
323
  arguments=arguments,
303
324
  meta=meta,
325
+ timeout_seconds=timeout_seconds,
304
326
  log_context=dict(get_contextvars()),
305
327
  result=result,
306
328
  )
@@ -37,6 +37,14 @@ class BackendConflictError(BackendError):
37
37
  status_code = 409
38
38
 
39
39
 
40
+ class BackendTimeoutError(BackendError):
41
+ """A platform request timed out; keep its limit without retaining the request."""
42
+
43
+ def __init__(self, message: str, *, timeout_seconds: float | None = None) -> None:
44
+ super().__init__(message)
45
+ self.timeout_seconds = timeout_seconds
46
+
47
+
40
48
  class LeaseLostError(TauSDKError):
41
49
  code = "runtime_lease_lost"
42
50
  status_code = 409
@@ -1152,6 +1152,8 @@ class SessionRuntimeManager:
1152
1152
  create_mcp_application_tools(
1153
1153
  bootstrap.mcp_applications,
1154
1154
  connect=application_connector(self.settings),
1155
+ tool_timeout_seconds=self.settings.mcp_tool_timeout_seconds,
1156
+ open_timeout_seconds=self.settings.backend_read_timeout_seconds,
1155
1157
  )
1156
1158
  )
1157
1159
  tools.extend(create_task_control_tools())
@@ -30,7 +30,7 @@ import structlog
30
30
  from ms_tau_sdk.backend.auth import AccessToken, _jwt_expiry
31
31
  from ms_tau_sdk.backend.client import LeaseProof, answer_without_request_credentials
32
32
  from ms_tau_sdk.backend.models import ReleaseRuntimeAccess
33
- from ms_tau_sdk.errors import BackendError, TauSDKError
33
+ from ms_tau_sdk.errors import BackendError, BackendTimeoutError, TauSDKError
34
34
 
35
35
  logger = structlog.get_logger(__name__)
36
36
 
@@ -276,8 +276,12 @@ class TurnRequesterBinding:
276
276
  )
277
277
  except BackendError as error:
278
278
  # A transport failure keeps the request, and its credentials, as its cause, so
279
- # only the message, status and answer are carried on.
280
- failure = BackendError(str(error), status_code=error.backend_status)
279
+ # only safe metadata is carried on, including a timeout's classification.
280
+ failure = (
281
+ BackendTimeoutError(str(error), timeout_seconds=error.timeout_seconds)
282
+ if isinstance(error, BackendTimeoutError)
283
+ else BackendError(str(error), status_code=error.backend_status)
284
+ )
281
285
  failure.detail = error.detail
282
286
  if failure is not None:
283
287
  if delegate and failure.backend_status == 403:
@@ -242,6 +242,12 @@ class TauSDKSettings(BaseSettings):
242
242
  ge=1,
243
243
  validation_alias="MAINSEQUENCE_TAU_MCP_READ_CONCURRENCY",
244
244
  )
245
+ # How long an application tool may run when its tool declares no time limit (ADR 0022).
246
+ mcp_tool_timeout_seconds: float = Field(
247
+ default=60.0,
248
+ gt=0,
249
+ validation_alias="TAU_MCP_TOOL_TIMEOUT_SECONDS",
250
+ )
245
251
  backend_max_response_bytes: int = Field(
246
252
  default=10 * 1024 * 1024,
247
253
  gt=0,
@@ -8,17 +8,22 @@ session for the call, and closes it afterwards. The token carries the delegation
8
8
  turn serves, when it serves one, and is the Agent's own otherwise; the application decides what the
9
9
  call may do. No session, token or catalog is shared between turns or people, and nothing is read
10
10
  when the session loads, because no turn is running then.
11
+
12
+ Each tool declares how long it may run in its ``_meta`` (ADR 0022); a tool that declares nothing
13
+ gets ``TAU_MCP_TOOL_TIMEOUT_SECONDS``. A failure reaches the model in plain words, and
14
+ opening the session is tried once more when it fails before the tool call is sent.
11
15
  """
12
16
 
13
17
  from __future__ import annotations
14
18
 
15
19
  import json
20
+ import math
16
21
  import re
17
22
  from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence
18
23
 
19
24
  import httpx
20
25
  import structlog
21
- from mcp import types
26
+ from mcp import McpError, types
22
27
  from tau_agent.messages import TextContent
23
28
  from tau_agent.tools import (
24
29
  AgentTool,
@@ -30,7 +35,7 @@ from tau_agent.types import JSONValue
30
35
 
31
36
  from ms_tau_sdk.backend.mcp import MCPConnectionClient
32
37
  from ms_tau_sdk.backend.models import MCPApplication
33
- from ms_tau_sdk.errors import BackendError, ConfigurationError
38
+ from ms_tau_sdk.errors import BackendError, BackendTimeoutError, ConfigurationError
34
39
  from ms_tau_sdk.runtime.requester import (
35
40
  RequesterBindingError,
36
41
  _turn_application_access,
@@ -44,7 +49,10 @@ logger = structlog.get_logger(__name__)
44
49
  _APPLICATION_NAME = re.compile(r"^[a-z][a-z0-9_]{0,39}$")
45
50
  _RESERVED_NAMES = frozenset({"mainsequence"})
46
51
 
47
- type ApplicationConnector = Callable[[MCPApplication, bool], Awaitable[MCPConnectionClient]]
52
+ TIMEOUT_META_KEY = "mainsequence.ai/timeout-seconds/v1"
53
+ """The ``_meta`` key under which an application tool declares how long it may run, in seconds."""
54
+
55
+ type ApplicationConnector = Callable[[MCPApplication], Awaitable[MCPConnectionClient]]
48
56
 
49
57
 
50
58
  class _ApplicationTokenAuth(httpx.Auth):
@@ -69,33 +77,116 @@ class _ApplicationTokenAuth(httpx.Auth):
69
77
  def application_connector(settings: TauSDKSettings) -> ApplicationConnector:
70
78
  """Open an MCP session to one application for the current turn."""
71
79
 
72
- async def connect(application: MCPApplication, read_catalog: bool) -> MCPConnectionClient:
80
+ async def connect(application: MCPApplication) -> MCPConnectionClient:
73
81
  access = await _turn_application_access(application.resource_release_uid)
74
82
  return await MCPConnectionClient(
75
83
  name=application.name,
76
84
  display_name=application.name,
77
85
  url=f"{str(access.rpc_url).rstrip('/')}/mcp",
78
86
  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.
79
89
  timeout=httpx.Timeout(
80
90
  connect=settings.backend_connect_timeout_seconds,
81
- read=settings.backend_read_timeout_seconds,
91
+ read=settings.turn_timeout_seconds,
82
92
  write=settings.backend_write_timeout_seconds,
83
93
  pool=settings.backend_pool_timeout_seconds,
84
94
  ),
85
95
  read_timeout_seconds=settings.backend_read_timeout_seconds,
86
96
  read_concurrency=settings.mcp_read_concurrency,
87
- read_catalog=read_catalog,
88
97
  ).open()
89
98
 
90
99
  return connect
91
100
 
92
101
 
93
- def _failure(name: str, error: BaseException) -> str:
102
+ def _timed_out(error: BaseException) -> bool:
103
+ if isinstance(error, McpError):
104
+ return error.error.code == httpx.codes.REQUEST_TIMEOUT
105
+ return isinstance(error, TimeoutError) or (
106
+ isinstance(error, httpx.TimeoutException) and not isinstance(error, httpx.ConnectTimeout)
107
+ )
108
+
109
+
110
+ def _failure(
111
+ name: str,
112
+ error: BaseException,
113
+ *,
114
+ tool: str | None,
115
+ timeout_seconds: float,
116
+ ) -> tuple[str, dict[str, JSONValue]]:
117
+ """What the model reads about a failure, and its details; never the error's own text."""
118
+
94
119
  if isinstance(error, RequesterBindingError):
95
- return "Your access for this request ended."
96
- if isinstance(error, BackendError) and error.backend_status in {403, 404}:
97
- return f"The {name} application is not available for this call."
98
- return f"The {name} application could not be reached."
120
+ return "Your access for this request ended.", {"failure": "access_ended"}
121
+ if isinstance(error, BackendError):
122
+ if isinstance(error, BackendTimeoutError) or error.backend_status in {408, 504}:
123
+ details: dict[str, JSONValue] = {"failure": "timeout", "phase": "access"}
124
+ if isinstance(error, BackendTimeoutError) and error.timeout_seconds is not None:
125
+ details["timeout_seconds"] = error.timeout_seconds
126
+ if error.backend_status is not None:
127
+ details["status"] = error.backend_status
128
+ return (
129
+ f"Main Sequence timed out while providing access to the {name} application.",
130
+ details,
131
+ )
132
+ if error.backend_status in {403, 404}:
133
+ return (
134
+ f"The {name} application is not available for this call.",
135
+ {"failure": "not_available"},
136
+ )
137
+ return (
138
+ f"Main Sequence could not provide access to the {name} application.",
139
+ {"failure": "access_unavailable"},
140
+ )
141
+ if _timed_out(error):
142
+ subject = f"The {name} tool {tool}" if tool is not None else f"The {name} application"
143
+ return (
144
+ f"{subject} did not answer within {timeout_seconds:g} seconds.",
145
+ {"failure": "timeout", "timeout_seconds": timeout_seconds},
146
+ )
147
+ if isinstance(error, httpx.HTTPStatusError):
148
+ status = error.response.status_code
149
+ if status in {408, 504}:
150
+ text = f"The {name} application or its gateway reported a timeout ({status})."
151
+ return text, {"failure": "timeout", "status": status}
152
+ if status >= 500:
153
+ text = f"The {name} application answered with a server error ({status})."
154
+ return text, {"failure": "server_error", "status": status}
155
+ if status in {401, 403}:
156
+ text = f"The {name} application refused this call ({status})."
157
+ return text, {"failure": "refused", "status": status}
158
+ text = f"The {name} application answered with an error ({status})."
159
+ return text, {"failure": "error_status", "status": status}
160
+ if isinstance(error, httpx.ConnectError | httpx.ConnectTimeout):
161
+ return f"The {name} application could not be reached.", {"failure": "unreachable"}
162
+ return f"The {name} application could not complete this call.", {"failure": "failed"}
163
+
164
+
165
+ def _retryable(error: BaseException) -> bool:
166
+ """Whether opening the session failed in a way that a second attempt may not repeat."""
167
+
168
+ if isinstance(error, httpx.HTTPStatusError):
169
+ return error.response.status_code == 408 or error.response.status_code >= 500
170
+ return isinstance(error, httpx.TransportError) or _timed_out(error)
171
+
172
+
173
+ def _declared_timeout(name: str, tool: types.Tool) -> float | None:
174
+ value = (tool.meta or {}).get(TIMEOUT_META_KEY)
175
+ if value is None:
176
+ return None
177
+ if (
178
+ isinstance(value, bool)
179
+ or not isinstance(value, int | float)
180
+ or not math.isfinite(value)
181
+ or value <= 0
182
+ ):
183
+ logger.warning(
184
+ "runtime.mcp_application.invalid_timeout",
185
+ application=name,
186
+ mcp_tool=tool.name,
187
+ )
188
+ return None
189
+ return float(value)
99
190
 
100
191
 
101
192
  def _error(name: str, text: str, **details: JSONValue) -> AgentToolResult:
@@ -118,9 +209,53 @@ def _application_tools(
118
209
  application: MCPApplication,
119
210
  *,
120
211
  connect: ApplicationConnector,
212
+ tool_timeout_seconds: float,
213
+ open_timeout_seconds: float,
121
214
  ) -> list[AgentTool]:
122
215
  name = application.name
123
216
 
217
+ async def open_session(signal: ToolCancellationToken | None) -> MCPConnectionClient:
218
+ # Nothing has reached a tool while the session opens, so one more attempt is safe.
219
+ try:
220
+ return await connect(application)
221
+ except Exception as error:
222
+ if not _retryable(error) or (signal is not None and signal.is_cancelled()):
223
+ raise
224
+ logger.info(
225
+ "runtime.mcp_application.retried",
226
+ application=name,
227
+ error_type=type(error).__name__,
228
+ )
229
+ return await connect(application)
230
+
231
+ def failed(
232
+ operation: str,
233
+ error: BaseException,
234
+ *,
235
+ mcp_tool: str | None = None,
236
+ call_timeout_seconds: float | None = None,
237
+ ) -> AgentToolResult:
238
+ # A failure after the call was sent, which has a time limit by then, is the tool's;
239
+ # a failure while the session opens is the application's.
240
+ text, failure = _failure(
241
+ name,
242
+ error,
243
+ tool=mcp_tool if call_timeout_seconds is not None else None,
244
+ timeout_seconds=(
245
+ call_timeout_seconds if call_timeout_seconds is not None else open_timeout_seconds
246
+ ),
247
+ )
248
+ if mcp_tool is not None:
249
+ failure["mcp_tool"] = mcp_tool
250
+ logger.warning(
251
+ "runtime.mcp_application.failed",
252
+ application=name,
253
+ operation=operation,
254
+ error_type=type(error).__name__,
255
+ **failure,
256
+ )
257
+ return _error(name, text, **failure)
258
+
124
259
  async def list_tools(
125
260
  tool_call_id: str,
126
261
  arguments: Mapping[str, JSONValue],
@@ -131,19 +266,13 @@ def _application_tools(
131
266
  if signal is not None and signal.is_cancelled():
132
267
  return _error(name, f"Listing the {name} tools was cancelled.", cancelled=True)
133
268
  try:
134
- client = await connect(application, True)
269
+ client = await open_session(signal)
135
270
  try:
136
271
  tools = client.tools
137
272
  finally:
138
273
  await client.aclose()
139
274
  except Exception as error:
140
- logger.warning(
141
- "runtime.mcp_application.failed",
142
- application=name,
143
- operation="list_tools",
144
- error_type=type(error).__name__,
145
- )
146
- return _error(name, _failure(name, error))
275
+ return failed("list_tools", error)
147
276
  entries = [_tool_entry(tool) for tool in tools]
148
277
  return AgentToolResult(
149
278
  content=[
@@ -170,20 +299,28 @@ def _application_tools(
170
299
  return _error(name, "`arguments` must be an object.")
171
300
  if signal is not None and signal.is_cancelled():
172
301
  return _error(name, f"The {name} tool call was cancelled.", cancelled=True)
302
+ call_timeout_seconds: float | None = None
173
303
  try:
174
- client = await connect(application, False)
304
+ client = await open_session(signal)
175
305
  try:
176
- result = await client.call_tool(tool_name, dict(tool_arguments))
306
+ declared = next(
307
+ (
308
+ _declared_timeout(name, tool)
309
+ for tool in client.tools
310
+ if tool.name == tool_name
311
+ ),
312
+ None,
313
+ )
314
+ call_timeout_seconds = declared if declared is not None else tool_timeout_seconds
315
+ result = await client.call_tool(
316
+ tool_name, dict(tool_arguments), timeout_seconds=call_timeout_seconds
317
+ )
177
318
  finally:
178
319
  await client.aclose()
179
320
  except Exception as error:
180
- logger.warning(
181
- "runtime.mcp_application.failed",
182
- application=name,
183
- operation="call_tool",
184
- error_type=type(error).__name__,
321
+ return failed(
322
+ "call_tool", error, mcp_tool=tool_name, call_timeout_seconds=call_timeout_seconds
185
323
  )
186
- return _error(name, _failure(name, error), mcp_tool=tool_name)
187
324
  projected = mcp_tool_result(canonical_name=tool_name, result=result, display_name=name)
188
325
  details = dict(projected.details) if isinstance(projected.details, dict) else {}
189
326
  details["application"] = name
@@ -230,8 +367,14 @@ def create_mcp_application_tools(
230
367
  applications: Sequence[MCPApplication],
231
368
  *,
232
369
  connect: ApplicationConnector,
370
+ tool_timeout_seconds: float,
371
+ open_timeout_seconds: float,
233
372
  ) -> list[AgentTool]:
234
- """Two tools for each declared application, in declaration order."""
373
+ """Two tools for each declared application, in declaration order.
374
+
375
+ ``tool_timeout_seconds`` bounds a call to a tool that declares no limit of its own;
376
+ ``open_timeout_seconds`` is the wait for each request that opens the session.
377
+ """
235
378
 
236
379
  tools: list[AgentTool] = []
237
380
  seen: set[str] = set()
@@ -242,5 +385,12 @@ def create_mcp_application_tools(
242
385
  if name in seen:
243
386
  raise ConfigurationError(f"Duplicate MCP application name: {name!r}")
244
387
  seen.add(name)
245
- tools.extend(_application_tools(application, connect=connect))
388
+ tools.extend(
389
+ _application_tools(
390
+ application,
391
+ connect=connect,
392
+ tool_timeout_seconds=tool_timeout_seconds,
393
+ open_timeout_seconds=open_timeout_seconds,
394
+ )
395
+ )
246
396
  return tools