ms-tau-sdk 2.0.9.dev48__tar.gz → 2.0.9.dev49__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.9.dev49}/CHANGELOG.md +18 -0
  2. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/PKG-INFO +1 -1
  3. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/pyproject.toml +1 -1
  4. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/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.9.dev49}/src/ms_tau_sdk/backend/mcp.py +32 -10
  6. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/runtime/manager.py +2 -0
  7. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/settings.py +6 -0
  8. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/tools/mcp_applications.py +163 -28
  9. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/.gitignore +0 -0
  10. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/README.md +0 -0
  11. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/packages/tau-board/src/ms_tau_board/__init__.py +0 -0
  12. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/packages/tau-board/src/ms_tau_board/app.py +0 -0
  13. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/packages/tau-board/src/ms_tau_board/cli.py +0 -0
  14. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/packages/tau-board/src/ms_tau_board/config.py +0 -0
  15. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/packages/tau-board/src/ms_tau_board/env_file.py +0 -0
  16. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/packages/tau-board/src/ms_tau_board/logs.py +0 -0
  17. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/packages/tau-board/src/ms_tau_board/proxy.py +0 -0
  18. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/packages/tau-board/src/ms_tau_board/state.py +0 -0
  19. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/packages/tau-board/src/ms_tau_board/static/BULMA-LICENSE.txt +0 -0
  20. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/packages/tau-board/src/ms_tau_board/static/app.js +0 -0
  21. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/packages/tau-board/src/ms_tau_board/static/board.css +0 -0
  22. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/packages/tau-board/src/ms_tau_board/static/bulma.min.css +0 -0
  23. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/packages/tau-board/src/ms_tau_board/static/index.html +0 -0
  24. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/__init__.py +0 -0
  25. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/agent_skills/tau_a2a_runtime_adapter/SKILL.md +0 -0
  26. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/agent_skills/tau_local_development/SKILL.md +0 -0
  27. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/agent_skills/tau_repository_integration/SKILL.md +0 -0
  28. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/agent_skills/tau_security_and_access/SKILL.md +0 -0
  29. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/api/__init__.py +0 -0
  30. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/api/a2a.py +0 -0
  31. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/api/chat.py +0 -0
  32. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/api/conversations.py +0 -0
  33. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/api/dependencies.py +0 -0
  34. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/api/health.py +0 -0
  35. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/api/inspection.py +0 -0
  36. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/api/local_chat.py +0 -0
  37. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/api/models.py +0 -0
  38. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/api/request_identity.py +0 -0
  39. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/api/sessions.py +0 -0
  40. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/app.py +0 -0
  41. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/application.py +0 -0
  42. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/backend/__init__.py +0 -0
  43. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/backend/assertions.py +0 -0
  44. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/backend/auth.py +0 -0
  45. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/backend/client.py +0 -0
  46. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/backend/local.py +0 -0
  47. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/backend/models.py +0 -0
  48. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/backend/routes.py +0 -0
  49. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/cli.py +0 -0
  50. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/errors.py +0 -0
  51. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/logging.py +0 -0
  52. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/protocols/__init__.py +0 -0
  53. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/protocols/a2a_failure.py +0 -0
  54. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/protocols/a2a_message.py +0 -0
  55. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/protocols/a2a_roles.py +0 -0
  56. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/protocols/assistant_ui.py +0 -0
  57. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/protocols/chat_history.py +0 -0
  58. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/protocols/strict_json.py +0 -0
  59. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/providers/__init__.py +0 -0
  60. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/providers/definitions.py +0 -0
  61. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/providers/factory.py +0 -0
  62. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/providers/tau_compat.py +0 -0
  63. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/resources/SYSTEM.md +0 -0
  64. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/resources/__init__.py +0 -0
  65. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/resources/loader.py +0 -0
  66. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/resources/prompts/review-code-repository.md +0 -0
  67. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/runtime/__init__.py +0 -0
  68. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/runtime/deployment_health.py +0 -0
  69. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/runtime/events.py +0 -0
  70. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/runtime/extensions.py +0 -0
  71. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/runtime/failures.py +0 -0
  72. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/runtime/live_turns.py +0 -0
  73. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/runtime/observability.py +0 -0
  74. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/runtime/provenance.py +0 -0
  75. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/runtime/requester.py +0 -0
  76. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/runtime/session.py +0 -0
  77. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/runtime/snapshots.py +0 -0
  78. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/runtime/task_context.py +0 -0
  79. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/sessions/__init__.py +0 -0
  80. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/sessions/storage.py +0 -0
  81. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/skills.py +0 -0
  82. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/tools/__init__.py +0 -0
  83. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/tools/mainsequence_mcp.py +0 -0
  84. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/tools/mcp_connection.py +0 -0
  85. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/tools/skill_read.py +0 -0
  86. {ms_tau_sdk-2.0.9.dev48 → ms_tau_sdk-2.0.9.dev49}/src/ms_tau_sdk/tools/task_control.py +0 -0
@@ -9,6 +9,24 @@
9
9
  the session loads, or, before the session has loaded, the one its Agent card names (one extra
10
10
  request, only then). Local turns carry the workspace's local Agent,
11
11
  `local-agent-<workspace digest>`.
12
+ - **Application tool calls get their own time limits, and failures say what happened
13
+ ([#84](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/84), ADR 0022).**
14
+ - A call that met an error status from the application or its gateway, such as 403, 502 or 503,
15
+ waited until the turn's 15-minute limit. It now ends at once with that status. The Main
16
+ Sequence MCP connection had the same defect and is fixed too.
17
+ - Each application tool declares how long it may run in its MCP `_meta` under
18
+ `mainsequence.ai/timeout-seconds/v1`, for example
19
+ `@mcp.tool(meta={"mainsequence.ai/timeout-seconds/v1": 300})`. A tool that declares nothing
20
+ gets the new `TAU_MCP_TOOL_TIMEOUT_SECONDS` (default `60`).
21
+ `MAINSEQUENCE_TAU_BACKEND_READ_TIMEOUT_SECONDS` no longer limits the call itself. To find the
22
+ limit, `<name>__call_tool` now reads the application's tool list before each call.
23
+ - The model reads what failed, for example "The orders tool rebuild_report did not answer
24
+ within 300 seconds." or "The orders application answered with a server error (502).", instead
25
+ of "could not be reached" for every failure. Details and the
26
+ `runtime.mcp_application.failed` log event carry `failure`, `status` and `timeout_seconds`.
27
+ - Opening the application's session is tried once more when it fails on the connection, a
28
+ time-out or a server error. A call that was sent is never sent again.
29
+ - See [Application tool time limits](docs/guides/application-tool-time-limits.md).
12
30
 
13
31
  ## 2.0.8 — 2026-10-08
14
32
 
@@ -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.9.dev49
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.9.dev49"
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:
@@ -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
  )
@@ -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())
@@ -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,
@@ -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,101 @@ 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, httpx.TimeoutException) and not isinstance(error, httpx.ConnectTimeout)
106
+
107
+
108
+ def _failure(
109
+ name: str,
110
+ error: BaseException,
111
+ *,
112
+ tool: str | None,
113
+ timeout_seconds: float,
114
+ ) -> tuple[str, dict[str, JSONValue]]:
115
+ """What the model reads about a failure, and its details; never the error's own text."""
116
+
94
117
  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."
118
+ return "Your access for this request ended.", {"failure": "access_ended"}
119
+ if isinstance(error, BackendError):
120
+ if error.backend_status in {403, 404}:
121
+ return (
122
+ f"The {name} application is not available for this call.",
123
+ {"failure": "not_available"},
124
+ )
125
+ return (
126
+ f"Main Sequence could not provide access to the {name} application.",
127
+ {"failure": "access_unavailable"},
128
+ )
129
+ if _timed_out(error):
130
+ subject = f"The {name} tool {tool}" if tool is not None else f"The {name} application"
131
+ return (
132
+ f"{subject} did not answer within {timeout_seconds:g} seconds.",
133
+ {"failure": "timeout", "timeout_seconds": timeout_seconds},
134
+ )
135
+ if isinstance(error, httpx.HTTPStatusError):
136
+ status = error.response.status_code
137
+ if status >= 500:
138
+ text = f"The {name} application answered with a server error ({status})."
139
+ return text, {"failure": "server_error", "status": status}
140
+ if status in {401, 403}:
141
+ text = f"The {name} application refused this call ({status})."
142
+ return text, {"failure": "refused", "status": status}
143
+ text = f"The {name} application answered with an error ({status})."
144
+ return text, {"failure": "error_status", "status": status}
145
+ if isinstance(error, httpx.ConnectError | httpx.ConnectTimeout):
146
+ return f"The {name} application could not be reached.", {"failure": "unreachable"}
147
+ return f"The {name} application could not complete this call.", {"failure": "failed"}
148
+
149
+
150
+ def _retryable(error: BaseException) -> bool:
151
+ """Whether opening the session failed in a way that a second attempt may not repeat."""
152
+
153
+ if isinstance(error, httpx.HTTPStatusError):
154
+ return error.response.status_code >= 500
155
+ return isinstance(error, httpx.TransportError) or _timed_out(error)
156
+
157
+
158
+ def _declared_timeout(name: str, tool: types.Tool) -> float | None:
159
+ value = (tool.meta or {}).get(TIMEOUT_META_KEY)
160
+ if value is None:
161
+ return None
162
+ if (
163
+ isinstance(value, bool)
164
+ or not isinstance(value, int | float)
165
+ or not math.isfinite(value)
166
+ or value <= 0
167
+ ):
168
+ logger.warning(
169
+ "runtime.mcp_application.invalid_timeout",
170
+ application=name,
171
+ mcp_tool=tool.name,
172
+ )
173
+ return None
174
+ return float(value)
99
175
 
100
176
 
101
177
  def _error(name: str, text: str, **details: JSONValue) -> AgentToolResult:
@@ -118,9 +194,53 @@ def _application_tools(
118
194
  application: MCPApplication,
119
195
  *,
120
196
  connect: ApplicationConnector,
197
+ tool_timeout_seconds: float,
198
+ open_timeout_seconds: float,
121
199
  ) -> list[AgentTool]:
122
200
  name = application.name
123
201
 
202
+ async def open_session(signal: ToolCancellationToken | None) -> MCPConnectionClient:
203
+ # Nothing has reached a tool while the session opens, so one more attempt is safe.
204
+ try:
205
+ return await connect(application)
206
+ except Exception as error:
207
+ if not _retryable(error) or (signal is not None and signal.is_cancelled()):
208
+ raise
209
+ logger.info(
210
+ "runtime.mcp_application.retried",
211
+ application=name,
212
+ error_type=type(error).__name__,
213
+ )
214
+ return await connect(application)
215
+
216
+ def failed(
217
+ operation: str,
218
+ error: BaseException,
219
+ *,
220
+ mcp_tool: str | None = None,
221
+ call_timeout_seconds: float | None = None,
222
+ ) -> AgentToolResult:
223
+ # A failure after the call was sent, which has a time limit by then, is the tool's;
224
+ # a failure while the session opens is the application's.
225
+ text, failure = _failure(
226
+ name,
227
+ error,
228
+ tool=mcp_tool if call_timeout_seconds is not None else None,
229
+ timeout_seconds=(
230
+ call_timeout_seconds if call_timeout_seconds is not None else open_timeout_seconds
231
+ ),
232
+ )
233
+ if mcp_tool is not None:
234
+ failure["mcp_tool"] = mcp_tool
235
+ logger.warning(
236
+ "runtime.mcp_application.failed",
237
+ application=name,
238
+ operation=operation,
239
+ error_type=type(error).__name__,
240
+ **failure,
241
+ )
242
+ return _error(name, text, **failure)
243
+
124
244
  async def list_tools(
125
245
  tool_call_id: str,
126
246
  arguments: Mapping[str, JSONValue],
@@ -131,19 +251,13 @@ def _application_tools(
131
251
  if signal is not None and signal.is_cancelled():
132
252
  return _error(name, f"Listing the {name} tools was cancelled.", cancelled=True)
133
253
  try:
134
- client = await connect(application, True)
254
+ client = await open_session(signal)
135
255
  try:
136
256
  tools = client.tools
137
257
  finally:
138
258
  await client.aclose()
139
259
  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))
260
+ return failed("list_tools", error)
147
261
  entries = [_tool_entry(tool) for tool in tools]
148
262
  return AgentToolResult(
149
263
  content=[
@@ -170,20 +284,28 @@ def _application_tools(
170
284
  return _error(name, "`arguments` must be an object.")
171
285
  if signal is not None and signal.is_cancelled():
172
286
  return _error(name, f"The {name} tool call was cancelled.", cancelled=True)
287
+ call_timeout_seconds: float | None = None
173
288
  try:
174
- client = await connect(application, False)
289
+ client = await open_session(signal)
175
290
  try:
176
- result = await client.call_tool(tool_name, dict(tool_arguments))
291
+ declared = next(
292
+ (
293
+ _declared_timeout(name, tool)
294
+ for tool in client.tools
295
+ if tool.name == tool_name
296
+ ),
297
+ None,
298
+ )
299
+ call_timeout_seconds = declared if declared is not None else tool_timeout_seconds
300
+ result = await client.call_tool(
301
+ tool_name, dict(tool_arguments), timeout_seconds=call_timeout_seconds
302
+ )
177
303
  finally:
178
304
  await client.aclose()
179
305
  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__,
306
+ return failed(
307
+ "call_tool", error, mcp_tool=tool_name, call_timeout_seconds=call_timeout_seconds
185
308
  )
186
- return _error(name, _failure(name, error), mcp_tool=tool_name)
187
309
  projected = mcp_tool_result(canonical_name=tool_name, result=result, display_name=name)
188
310
  details = dict(projected.details) if isinstance(projected.details, dict) else {}
189
311
  details["application"] = name
@@ -230,8 +352,14 @@ def create_mcp_application_tools(
230
352
  applications: Sequence[MCPApplication],
231
353
  *,
232
354
  connect: ApplicationConnector,
355
+ tool_timeout_seconds: float,
356
+ open_timeout_seconds: float,
233
357
  ) -> list[AgentTool]:
234
- """Two tools for each declared application, in declaration order."""
358
+ """Two tools for each declared application, in declaration order.
359
+
360
+ ``tool_timeout_seconds`` bounds a call to a tool that declares no limit of its own;
361
+ ``open_timeout_seconds`` is the wait for each request that opens the session.
362
+ """
235
363
 
236
364
  tools: list[AgentTool] = []
237
365
  seen: set[str] = set()
@@ -242,5 +370,12 @@ def create_mcp_application_tools(
242
370
  if name in seen:
243
371
  raise ConfigurationError(f"Duplicate MCP application name: {name!r}")
244
372
  seen.add(name)
245
- tools.extend(_application_tools(application, connect=connect))
373
+ tools.extend(
374
+ _application_tools(
375
+ application,
376
+ connect=connect,
377
+ tool_timeout_seconds=tool_timeout_seconds,
378
+ open_timeout_seconds=open_timeout_seconds,
379
+ )
380
+ )
246
381
  return tools