ms-tau-sdk 2.0.13.dev54__tar.gz → 2.0.14.dev57__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.13.dev54 → ms_tau_sdk-2.0.14.dev57}/CHANGELOG.md +18 -0
  2. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/PKG-INFO +1 -1
  3. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/pyproject.toml +1 -1
  4. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/agent_skills/tau_project_customization/SKILL.md +10 -0
  5. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/backend/mcp.py +125 -13
  6. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/tools/mcp_applications.py +260 -10
  7. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/.gitignore +0 -0
  8. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/README.md +0 -0
  9. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/packages/tau-board/src/ms_tau_board/__init__.py +0 -0
  10. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/packages/tau-board/src/ms_tau_board/app.py +0 -0
  11. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/packages/tau-board/src/ms_tau_board/cli.py +0 -0
  12. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/packages/tau-board/src/ms_tau_board/config.py +0 -0
  13. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/packages/tau-board/src/ms_tau_board/env_file.py +0 -0
  14. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/packages/tau-board/src/ms_tau_board/logs.py +0 -0
  15. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/packages/tau-board/src/ms_tau_board/proxy.py +0 -0
  16. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/packages/tau-board/src/ms_tau_board/state.py +0 -0
  17. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/packages/tau-board/src/ms_tau_board/static/BULMA-LICENSE.txt +0 -0
  18. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/packages/tau-board/src/ms_tau_board/static/app.js +0 -0
  19. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/packages/tau-board/src/ms_tau_board/static/board.css +0 -0
  20. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/packages/tau-board/src/ms_tau_board/static/bulma.min.css +0 -0
  21. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/packages/tau-board/src/ms_tau_board/static/index.html +0 -0
  22. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/__init__.py +0 -0
  23. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/agent_skills/tau_a2a_runtime_adapter/SKILL.md +0 -0
  24. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/agent_skills/tau_local_development/SKILL.md +0 -0
  25. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/agent_skills/tau_repository_integration/SKILL.md +0 -0
  26. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/agent_skills/tau_security_and_access/SKILL.md +0 -0
  27. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/api/__init__.py +0 -0
  28. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/api/a2a.py +0 -0
  29. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/api/chat.py +0 -0
  30. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/api/conversations.py +0 -0
  31. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/api/dependencies.py +0 -0
  32. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/api/health.py +0 -0
  33. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/api/inspection.py +0 -0
  34. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/api/local_chat.py +0 -0
  35. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/api/models.py +0 -0
  36. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/api/request_identity.py +0 -0
  37. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/api/sessions.py +0 -0
  38. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/app.py +0 -0
  39. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/application.py +0 -0
  40. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/backend/__init__.py +0 -0
  41. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/backend/assertions.py +0 -0
  42. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/backend/auth.py +0 -0
  43. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/backend/client.py +0 -0
  44. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/backend/local.py +0 -0
  45. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/backend/models.py +0 -0
  46. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/backend/routes.py +0 -0
  47. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/cli.py +0 -0
  48. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/errors.py +0 -0
  49. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/logging.py +0 -0
  50. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/protocols/__init__.py +0 -0
  51. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/protocols/a2a_failure.py +0 -0
  52. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/protocols/a2a_message.py +0 -0
  53. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/protocols/a2a_roles.py +0 -0
  54. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/protocols/assistant_ui.py +0 -0
  55. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/protocols/chat_history.py +0 -0
  56. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/protocols/strict_json.py +0 -0
  57. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/providers/__init__.py +0 -0
  58. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/providers/definitions.py +0 -0
  59. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/providers/factory.py +0 -0
  60. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/providers/tau_compat.py +0 -0
  61. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/resources/SYSTEM.md +0 -0
  62. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/resources/__init__.py +0 -0
  63. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/resources/loader.py +0 -0
  64. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/resources/prompts/review-code-repository.md +0 -0
  65. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/runtime/__init__.py +0 -0
  66. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/runtime/deployment_health.py +0 -0
  67. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/runtime/events.py +0 -0
  68. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/runtime/extensions.py +0 -0
  69. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/runtime/failures.py +0 -0
  70. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/runtime/live_turns.py +0 -0
  71. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/runtime/manager.py +0 -0
  72. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/runtime/observability.py +0 -0
  73. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/runtime/provenance.py +0 -0
  74. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/runtime/requester.py +0 -0
  75. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/runtime/session.py +0 -0
  76. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/runtime/snapshots.py +0 -0
  77. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/runtime/task_context.py +0 -0
  78. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/sessions/__init__.py +0 -0
  79. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/sessions/storage.py +0 -0
  80. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/settings.py +0 -0
  81. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/skills.py +0 -0
  82. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/tools/__init__.py +0 -0
  83. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/tools/mainsequence_mcp.py +0 -0
  84. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/tools/mcp_connection.py +0 -0
  85. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/tools/skill_read.py +0 -0
  86. {ms_tau_sdk-2.0.13.dev54 → ms_tau_sdk-2.0.14.dev57}/src/ms_tau_sdk/tools/task_control.py +0 -0
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - **An Agent reads the skills and resources its declared applications publish
6
+ ([#98](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/98), ADR 0023).** An application
7
+ that published skills through the MCP Skills extension, as the mainsequence SDK's skills
8
+ publisher does, reached its Agent only through `<name>__list_tools` and `<name>__call_tool`: the
9
+ Agent could not discover or read the skills. Each declared application now also gets
10
+ `<name>__list_resources`, which lists its skills, each with its files, and its other resources,
11
+ and `<name>__read_resource`, which reads one of them by URI. `<name>__list_tools` says when the
12
+ application publishes them. An application's session first asks MCP's `server/discover` on
13
+ protocol `2026-07-28`, where the extension is declared, and opens an application that does not
14
+ answer it with `initialize`, as before; the platform's own MCP is unchanged. Only what the
15
+ application lists is read, and a skill file is returned only when its bytes match the size and
16
+ SHA-256 digest of the skill's list. Every read names the application. Published skills never
17
+ become the project's skills and grant nothing; with the coding tools, a model that follows one
18
+ can run commands, which the [Security and access guide](docs/reference/security-model.md#calling-other-applications)
19
+ now states.
20
+
21
+ ## 2.0.13 — 2026-10-12
22
+
5
23
  - **ms-tau-sdk installs the MCP Python SDK 2.x.** The `mcp` dependency moves from `mcp>=1.27,<2`
6
24
  to `mcp>=2.3,<3`, which brings `httpx2`, the HTTP client MCP 2.x uses. A repository that runs a
7
25
  Harness Agent and also serves an application MCP with `mainsequence[mcp]`, which requires MCP 2.x
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ms-tau-sdk
3
- Version: 2.0.13.dev54
3
+ Version: 2.0.14.dev57
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.13.dev54"
7
+ version = "2.0.14.dev57"
8
8
  description = "Workspace-bound Tau application primitives for Main Sequence projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -96,6 +96,16 @@ retry guidance for up to `TAU_MCP_APPLICATION_READY_TIMEOUT_SECONDS` (default `1
96
96
  turn time. This startup budget is separate from the tool's execution limit; an expired readiness
97
97
  wait reports `readiness_timeout`. Cancellation, ended delegation and terminal refusals stop the wait.
98
98
 
99
+ ## Skills that applications publish
100
+
101
+ A declared application can publish Agent Skills through the MCP Skills extension, as the
102
+ mainsequence SDK's skills publisher does. The Agent lists them with `<name>__list_resources` and
103
+ reads one file with `<name>__read_resource`; a skill file is returned only when its bytes match the
104
+ size and SHA-256 digest the skill lists. Published skills stay the application's: they are not
105
+ project skills under `.tau/skills/`, and fields such as `allowed-tools` grant nothing. With the
106
+ coding tools, a model that follows a published skill can run commands; set
107
+ `TAU_EXCLUDE_BASE_TOOLS=true` when that must not happen.
108
+
99
109
  ## Invariants extensions must not replace
100
110
 
101
111
  Extensions may change effective agent capabilities, but they must not replace or bypass:
@@ -9,11 +9,12 @@ from collections.abc import AsyncGenerator, AsyncIterator
9
9
  from contextlib import asynccontextmanager
10
10
  from dataclasses import dataclass
11
11
  from types import TracebackType
12
- from typing import Any, Literal, Self, cast
12
+ from typing import Annotated, Any, Literal, Self, cast
13
13
 
14
14
  import anyio
15
15
  import httpx2
16
16
  from mcp import ClientSession, types
17
+ from mcp.client._probe import negotiate_auto
17
18
  from mcp.client.streamable_http import RequestContext, StreamableHTTPTransport
18
19
  from mcp.shared._context_streams import (
19
20
  ContextReceiveStream,
@@ -21,6 +22,7 @@ from mcp.shared._context_streams import (
21
22
  create_context_streams,
22
23
  )
23
24
  from mcp.shared.message import SessionMessage
25
+ from pydantic import BaseModel, Field, model_validator
24
26
  from structlog.contextvars import bound_contextvars, get_contextvars
25
27
 
26
28
  from ms_tau_sdk.settings import TauSDKSettings
@@ -218,6 +220,73 @@ def _validate_response_timeout(seconds: float) -> None:
218
220
  raise ValueError("MCP response timeout must be finite and greater than zero")
219
221
 
220
222
 
223
+ SKILLS_EXTENSION = "io.modelcontextprotocol/skills"
224
+ """The MCP Skills extension (SEP-2640). A server declares it only on protocol 2026-07-28."""
225
+
226
+
227
+ class SkillFile(BaseModel):
228
+ """One file of a published skill: its URI, and the size and SHA-256 digest of its bytes."""
229
+
230
+ uri: str
231
+ digest: Annotated[str, Field(pattern=r"^sha256:[0-9a-f]{64}$")]
232
+ size: Annotated[int, Field(ge=0)]
233
+
234
+
235
+ class Skill(BaseModel):
236
+ """One skill a server publishes: the URI of its ``SKILL.md``, its frontmatter and its files.
237
+
238
+ ``resources`` lists every file of the skill, or is ``"dynamic"`` when the server generates the
239
+ skill's content and publishes no digests.
240
+ """
241
+
242
+ uri: Annotated[str, Field(pattern=r"/SKILL\.md$")]
243
+ frontmatter: dict[str, Any]
244
+ resources: list[SkillFile] | Literal["dynamic"]
245
+
246
+ @model_validator(mode="after")
247
+ def _named_and_described(self) -> Self:
248
+ for field in ("name", "description"):
249
+ if not isinstance(self.frontmatter.get(field), str):
250
+ raise ValueError(f"the frontmatter of skill {self.uri} has no {field}")
251
+ return self
252
+
253
+
254
+ class _ListSkillsRequest(types.PaginatedRequest[Literal["skills/list"]]):
255
+ method: Literal["skills/list"] = "skills/list"
256
+
257
+
258
+ class _ListSkillsResult(types.PaginatedResult, types.CacheableResult):
259
+ skills: list[Skill]
260
+
261
+
262
+ async def _list_skills(session: ClientSession) -> tuple[Skill, ...]:
263
+ """Read every page of ``skills/list``; a skill listed twice counts once."""
264
+
265
+ skills: dict[str, Skill] = {}
266
+ cursor: str | None = None
267
+ cursors: set[str] = set()
268
+ while True:
269
+ page = await session.send_request(
270
+ _ListSkillsRequest(
271
+ params=types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None
272
+ ),
273
+ _ListSkillsResult,
274
+ )
275
+ for skill in page.skills:
276
+ skills.setdefault(skill.uri, skill)
277
+ cursor = page.next_cursor
278
+ if cursor is None:
279
+ return tuple(skills.values())
280
+ if cursor in cursors:
281
+ raise ValueError("the server repeated a skills/list cursor")
282
+ cursors.add(cursor)
283
+
284
+
285
+ def _sends(request: httpx2.Request, method: str) -> bool:
286
+ message = types.jsonrpc_message_adapter.validate_json(request.content)
287
+ return isinstance(message, types.JSONRPCRequest) and message.method == method
288
+
289
+
221
290
  @dataclass(frozen=True)
222
291
  class _CallToolCommand:
223
292
  name: str
@@ -235,12 +304,18 @@ class _ReadResourceCommand:
235
304
  result: asyncio.Future[types.ReadResourceResult]
236
305
 
237
306
 
307
+ @dataclass(frozen=True)
308
+ class _ListSkillsCommand:
309
+ log_context: dict[str, object]
310
+ result: asyncio.Future[tuple[Skill, ...]]
311
+
312
+
238
313
  @dataclass(frozen=True)
239
314
  class _CloseCommand:
240
315
  reason: Literal["client_close"] = "client_close"
241
316
 
242
317
 
243
- type _MCPCommand = _CallToolCommand | _ReadResourceCommand | _CloseCommand
318
+ type _MCPCommand = _CallToolCommand | _ReadResourceCommand | _ListSkillsCommand | _CloseCommand
244
319
 
245
320
  ENVIRONMENT_SCOPED_AGENT_TOOLS = frozenset({"agent.list", "agent.search"})
246
321
  ENVIRONMENT_UID_ARGUMENT = "organization_environment_uid"
@@ -258,7 +333,9 @@ class MCPConnectionClient:
258
333
  """One remote MCP server over Streamable HTTP.
259
334
 
260
335
  Each client owns its own MCP session, tool catalog and resources; nothing is shared between
261
- connections.
336
+ connections. With ``mode="legacy"`` the session opens with the ``initialize`` handshake. With
337
+ ``mode="auto"``, MCP's own default, it first asks protocol 2026-07-28's ``server/discover``,
338
+ where a server declares its extensions, and opens an older server with ``initialize``.
262
339
  """
263
340
 
264
341
  def __init__(
@@ -271,6 +348,7 @@ class MCPConnectionClient:
271
348
  timeout: httpx2.Timeout,
272
349
  read_timeout_seconds: float,
273
350
  read_concurrency: int,
351
+ mode: Literal["legacy", "auto"] = "legacy",
274
352
  ) -> None:
275
353
  _validate_response_timeout(read_timeout_seconds)
276
354
  self.name = name
@@ -280,6 +358,7 @@ class MCPConnectionClient:
280
358
  self._http_timeout = timeout
281
359
  self._read_timeout_seconds = read_timeout_seconds
282
360
  self._read_concurrency = read_concurrency
361
+ self._mode = mode
283
362
  self._commands: asyncio.Queue[_MCPCommand] | None = None
284
363
  self._owner_task: asyncio.Task[None] | None = None
285
364
  self._ready: asyncio.Future[None] | None = None
@@ -290,6 +369,7 @@ class MCPConnectionClient:
290
369
  self._http_calls: dict[types.RequestId, _ToolCall] = {}
291
370
  self.tools: tuple[types.Tool, ...] = ()
292
371
  self.resources: tuple[types.Resource, ...] = ()
372
+ self.extensions: dict[str, dict[str, Any]] = {}
293
373
  self._parallel_tool_names: frozenset[str] = frozenset()
294
374
  self._closed = False
295
375
 
@@ -346,8 +426,7 @@ class MCPConnectionClient:
346
426
  read_timeout_seconds=self._read_timeout_seconds,
347
427
  ) as session:
348
428
  with bound_contextvars(**initial_log_context):
349
- initialized = await session.initialize()
350
- await self._read_server_catalog(session, initialized)
429
+ await self._read_server_catalog(session, await self._negotiate(session))
351
430
  self._parallel_tool_names = frozenset(
352
431
  tool.name
353
432
  for tool in self.tools
@@ -391,16 +470,27 @@ class MCPConnectionClient:
391
470
  # MCP 2.x answers an HTTP error on a message POST with a JSON-RPC error that drops the
392
471
  # status. Raise it, as MCP 1.x did, so a failure still says refused, server error or
393
472
  # timeout. 401 is left to the credential's renewal, and 404 to MCP, as 1.x did.
394
- if response.request.method == "POST" and response.status_code not in {401, 404}:
395
- response.raise_for_status()
473
+ if response.request.method != "POST" or response.status_code in {401, 404}:
474
+ return
475
+ # An older server can refuse the `server/discover` probe with a client error. MCP then
476
+ # opens the session with `initialize` instead.
477
+ if response.is_client_error and _sends(response.request, "server/discover"):
478
+ return
479
+ response.raise_for_status()
480
+
481
+ async def _negotiate(self, session: ClientSession) -> types.ServerCapabilities | None:
482
+ if self._mode == "auto":
483
+ await negotiate_auto(session)
484
+ return session.server_capabilities
485
+ initialized = await session.initialize()
486
+ return initialized.capabilities if initialized is not None else None
396
487
 
397
488
  async def _read_server_catalog(
398
489
  self,
399
490
  session: ClientSession,
400
- initialized: types.InitializeResult | None,
491
+ capabilities: types.ServerCapabilities | None,
401
492
  ) -> None:
402
493
  # Read only what the server offers; a server without resources answers "not found".
403
- capabilities = initialized.capabilities if initialized is not None else None
404
494
  offers_tools = capabilities is None or capabilities.tools is not None
405
495
  offers_resources = capabilities is None or capabilities.resources is not None
406
496
  tools_result, resources_result = await asyncio.gather(
@@ -409,6 +499,7 @@ class MCPConnectionClient:
409
499
  )
410
500
  self.tools = tuple(tools_result.tools)
411
501
  self.resources = tuple(resources_result.resources)
502
+ self.extensions = dict(capabilities.extensions or {}) if capabilities is not None else {}
412
503
 
413
504
  async def _serve(self, session: ClientSession) -> None:
414
505
  commands = self._commands
@@ -417,7 +508,9 @@ class MCPConnectionClient:
417
508
  active_reads: set[asyncio.Task[None]] = set()
418
509
  read_slots = asyncio.Semaphore(self._read_concurrency)
419
510
 
420
- async def execute(command: _CallToolCommand | _ReadResourceCommand) -> None:
511
+ async def execute(
512
+ command: _CallToolCommand | _ReadResourceCommand | _ListSkillsCommand,
513
+ ) -> None:
421
514
  if command.result.cancelled():
422
515
  return
423
516
  self._in_flight.add(command.result)
@@ -450,10 +543,14 @@ class MCPConnectionClient:
450
543
  command.result.remove_done_callback(call.cancel_if_abandoned)
451
544
  _tool_call.reset(token)
452
545
  call.finish()
453
- else:
546
+ elif isinstance(command, _ReadResourceCommand):
454
547
  resource_result = await session.read_resource(command.uri)
455
548
  if not command.result.done():
456
549
  command.result.set_result(resource_result)
550
+ else:
551
+ skills = await _list_skills(session)
552
+ if not command.result.done():
553
+ command.result.set_result(skills)
457
554
  except Exception as error:
458
555
  if not command.result.done():
459
556
  command.result.set_exception(error)
@@ -464,7 +561,7 @@ class MCPConnectionClient:
464
561
  self._in_flight.discard(command.result)
465
562
 
466
563
  async def execute_read(
467
- command: _CallToolCommand | _ReadResourceCommand,
564
+ command: _CallToolCommand | _ReadResourceCommand | _ListSkillsCommand,
468
565
  ) -> None:
469
566
  async with read_slots:
470
567
  await execute(command)
@@ -479,7 +576,9 @@ class MCPConnectionClient:
479
576
  if isinstance(command, _CloseCommand):
480
577
  await drain_reads()
481
578
  return
482
- is_parallel_read = isinstance(command, _ReadResourceCommand) or (
579
+ is_parallel_read = isinstance(
580
+ command, _ReadResourceCommand | _ListSkillsCommand
581
+ ) or (
483
582
  isinstance(command, _CallToolCommand)
484
583
  and command.name in self._parallel_tool_names
485
584
  )
@@ -563,6 +662,19 @@ class MCPConnectionClient:
563
662
  )
564
663
  return await result
565
664
 
665
+ async def list_skills(self) -> tuple[Skill, ...]:
666
+ """Every skill the server publishes; none when it does not declare ``SKILLS_EXTENSION``.
667
+
668
+ Only a server that declares the extension is asked, as the extension requires.
669
+ """
670
+
671
+ if SKILLS_EXTENSION not in self.extensions:
672
+ return ()
673
+ commands = self._require_commands()
674
+ result: asyncio.Future[tuple[Skill, ...]] = asyncio.get_running_loop().create_future()
675
+ commands.put_nowait(_ListSkillsCommand(log_context=dict(get_contextvars()), result=result))
676
+ return await result
677
+
566
678
  async def aclose(self) -> None:
567
679
  if self._closed:
568
680
  return
@@ -1,21 +1,29 @@
1
- """MCP endpoints of the applications an Agent declares (ADR 0021, section 4).
1
+ """MCP endpoints of the applications an Agent declares (ADR 0021, section 4; ADR 0023).
2
2
 
3
3
  Declaring an application registers its MCP; it grants no access. The platform resolves each
4
4
  declared application in the Agent's Environment and hands the runtime its name and release at
5
- startup. Each application gets two tools, ``<name>__list_tools`` and ``<name>__call_tool``. Both run
6
- inside the turn: the SDK obtains the application's address and a short-lived token, opens an MCP
7
- session for the call, and closes it afterwards. The token carries the delegation of the person the
8
- turn serves, when it serves one, and is the Agent's own otherwise; the application decides what the
9
- call may do. No session, token or catalog is shared between turns or people, and nothing is read
10
- when the session loads, because no turn is running then.
5
+ startup. Each application gets four tools: ``<name>__list_tools`` and ``<name>__call_tool`` for its
6
+ tools, and ``<name>__list_resources`` and ``<name>__read_resource`` for the skills and resources it
7
+ publishes. They run inside the turn: the SDK obtains the application's address and a short-lived
8
+ token, opens an MCP session for the call, and closes it afterwards. The token carries the
9
+ delegation of the person the turn serves, when it serves one, and is the Agent's own otherwise; the
10
+ application decides what the call may do. No session, token or catalog is shared between turns or
11
+ people, and nothing is read when the session loads, because no turn is running then.
11
12
 
12
13
  Each tool declares how long it may run in its ``_meta`` (ADR 0022); a tool that declares nothing
13
14
  gets ``TAU_MCP_TOOL_TIMEOUT_SECONDS``. A failure reaches the model in plain words, and
14
15
  opening the session is tried once more when it fails before the tool call is sent.
16
+
17
+ A file of a skill the application lists (MCP Skills extension) reaches the model only when its
18
+ bytes match the size and SHA-256 digest the listing gives. Published skills are the application's
19
+ content: the SDK never adds them to the project's skills and grants nothing they ask for.
15
20
  """
16
21
 
17
22
  from __future__ import annotations
18
23
 
24
+ import base64
25
+ import binascii
26
+ import hashlib
19
27
  import json
20
28
  import re
21
29
  from collections.abc import AsyncGenerator, Mapping, Sequence
@@ -34,7 +42,13 @@ from tau_agent.tools import (
34
42
  )
35
43
  from tau_agent.types import JSONValue
36
44
 
37
- from ms_tau_sdk.backend.mcp import MCPConnectionClient, raise_for_renewed_401
45
+ from ms_tau_sdk.backend.mcp import (
46
+ SKILLS_EXTENSION,
47
+ MCPConnectionClient,
48
+ Skill,
49
+ SkillFile,
50
+ raise_for_renewed_401,
51
+ )
38
52
  from ms_tau_sdk.backend.models import MCPApplication
39
53
  from ms_tau_sdk.errors import (
40
54
  ApplicationReadinessTimeoutError,
@@ -109,6 +123,8 @@ def application_connector(settings: TauSDKSettings) -> ApplicationConnector:
109
123
  ),
110
124
  read_timeout_seconds=settings.backend_read_timeout_seconds,
111
125
  read_concurrency=settings.mcp_read_concurrency,
126
+ # An application declares the Skills extension only on the newer protocol revision.
127
+ mode="auto",
112
128
  ).open()
113
129
 
114
130
  return connect
@@ -222,6 +238,71 @@ def _tool_entry(tool: types.Tool) -> dict[str, JSONValue]:
222
238
  return entry
223
239
 
224
240
 
241
+ def _owner(skills: Sequence[Skill], uri: str) -> Skill | None:
242
+ """The listed skill whose directory holds ``uri``; the innermost one when skills nest."""
243
+
244
+ owners = [skill for skill in skills if uri.startswith(skill.uri.removesuffix("SKILL.md"))]
245
+ return max(owners, key=lambda skill: len(skill.uri), default=None)
246
+
247
+
248
+ def _listed_by_skill(skills: Sequence[Skill], uri: str) -> bool:
249
+ """Whether ``uri`` is read as a file of a listed skill rather than as a plain resource.
250
+
251
+ A skill with a file list owns its whole directory. A generated (``"dynamic"``) skill lists no
252
+ files, so only its ``SKILL.md`` is its own.
253
+ """
254
+
255
+ owner = _owner(skills, uri)
256
+ return owner is not None and (owner.resources != "dynamic" or uri == owner.uri)
257
+
258
+
259
+ def _skill_entry(skill: Skill) -> dict[str, JSONValue]:
260
+ entry: dict[str, JSONValue] = {
261
+ "uri": skill.uri,
262
+ "name": skill.frontmatter["name"],
263
+ "description": skill.frontmatter["description"],
264
+ }
265
+ if skill.resources != "dynamic":
266
+ entry["files"] = [{"uri": file.uri, "size": file.size} for file in skill.resources]
267
+ return entry
268
+
269
+
270
+ def _resource_entry(resource: types.Resource) -> dict[str, JSONValue]:
271
+ entry: dict[str, JSONValue] = {"uri": str(resource.uri), "name": resource.name}
272
+ if resource.description:
273
+ entry["description"] = resource.description
274
+ if resource.mime_type:
275
+ entry["mimeType"] = resource.mime_type
276
+ return entry
277
+
278
+
279
+ def _matches(
280
+ contents: Sequence[types.TextResourceContents | types.BlobResourceContents],
281
+ listed: SkillFile,
282
+ ) -> bool:
283
+ """Whether a read returned exactly one content, with the bytes the skill lists."""
284
+
285
+ if len(contents) != 1:
286
+ return False
287
+ content = contents[0]
288
+ if isinstance(content, types.TextResourceContents):
289
+ data = content.text.encode()
290
+ else:
291
+ try:
292
+ data = base64.b64decode(content.blob, validate=True)
293
+ except binascii.Error:
294
+ return False
295
+ return (
296
+ len(data) == listed.size and f"sha256:{hashlib.sha256(data).hexdigest()}" == listed.digest
297
+ )
298
+
299
+
300
+ def _content_text(content: types.TextResourceContents | types.BlobResourceContents) -> str:
301
+ if isinstance(content, types.TextResourceContents):
302
+ return content.text
303
+ return f"(Binary content of type {content.mime_type or 'unknown'}; not shown.)"
304
+
305
+
225
306
  def _application_tools(
226
307
  application: MCPApplication,
227
308
  *,
@@ -286,21 +367,158 @@ def _application_tools(
286
367
  client = await open_session(signal)
287
368
  try:
288
369
  tools = client.tools
370
+ publishes_skills = SKILLS_EXTENSION in client.extensions
371
+ publishes_resources = bool(client.resources)
289
372
  finally:
290
373
  await client.aclose()
291
374
  except Exception as error:
292
375
  return failed("list_tools", error)
293
376
  entries = [_tool_entry(tool) for tool in tools]
377
+ published = ""
378
+ if publishes_skills:
379
+ published = (
380
+ " It also publishes skills, instructions for using its tools: list them with "
381
+ f"{name}__list_resources."
382
+ )
383
+ elif publishes_resources:
384
+ published = f" It also publishes resources: list them with {name}__list_resources."
294
385
  return AgentToolResult(
295
386
  content=[
296
387
  TextContent(
297
- text=f"Tools of the {name} application. Call one with {name}__call_tool.\n"
388
+ text=f"Tools of the {name} application. Call one with {name}__call_tool."
389
+ + published
390
+ + "\n"
298
391
  + json.dumps(entries, indent=2, sort_keys=True, default=str)
299
392
  )
300
393
  ],
301
394
  details={"application": name, "is_error": False, "tools": [t.name for t in tools]},
302
395
  )
303
396
 
397
+ async def list_resources(
398
+ tool_call_id: str,
399
+ arguments: Mapping[str, JSONValue],
400
+ signal: ToolCancellationToken | None = None,
401
+ on_update: ToolUpdateCallback | None = None,
402
+ ) -> AgentToolResult:
403
+ del tool_call_id, arguments, on_update
404
+ if signal is not None and signal.is_cancelled():
405
+ return _error(name, f"Listing the {name} resources was cancelled.", cancelled=True)
406
+ try:
407
+ client = await open_session(signal)
408
+ try:
409
+ skills = await client.list_skills()
410
+ resources = client.resources
411
+ finally:
412
+ await client.aclose()
413
+ except Exception as error:
414
+ return failed("list_resources", error)
415
+ listing: dict[str, list[dict[str, JSONValue]]] = {
416
+ "skills": [_skill_entry(skill) for skill in skills],
417
+ "resources": [
418
+ _resource_entry(resource)
419
+ for resource in resources
420
+ if not _listed_by_skill(skills, str(resource.uri))
421
+ ],
422
+ }
423
+ if listing["skills"] or listing["resources"]:
424
+ text = (
425
+ f"Skills and resources the {name} application publishes. A skill is the "
426
+ f"application's instructions for using its tools. Read a skill file or a resource "
427
+ f"with {name}__read_resource.\n"
428
+ + json.dumps(listing, indent=2, sort_keys=True, default=str)
429
+ )
430
+ else:
431
+ text = f"The {name} application publishes no skills or resources."
432
+ return AgentToolResult(
433
+ content=[TextContent(text=text)],
434
+ details={
435
+ "application": name,
436
+ "is_error": False,
437
+ "skills": [skill.uri for skill in skills],
438
+ "resources": [entry["uri"] for entry in listing["resources"]],
439
+ },
440
+ )
441
+
442
+ async def read_resource(
443
+ tool_call_id: str,
444
+ arguments: Mapping[str, JSONValue],
445
+ signal: ToolCancellationToken | None = None,
446
+ on_update: ToolUpdateCallback | None = None,
447
+ ) -> AgentToolResult:
448
+ del tool_call_id, on_update
449
+ uri = arguments.get("uri")
450
+ if not isinstance(uri, str) or not uri:
451
+ return _error(name, f"Name the {name} skill file or resource to read in `uri`.")
452
+ if signal is not None and signal.is_cancelled():
453
+ return _error(name, f"Reading the {name} resource was cancelled.", cancelled=True)
454
+ result: types.ReadResourceResult | None = None
455
+ owner: Skill | None = None
456
+ listed: SkillFile | None = None
457
+ try:
458
+ client = await open_session(signal)
459
+ try:
460
+ # Only what the application lists is read: a file of a listed skill, which must
461
+ # match the skill's list, or an advertised resource.
462
+ owner = _owner(await client.list_skills(), uri)
463
+ if owner is not None and owner.resources != "dynamic":
464
+ listed = next((file for file in owner.resources if file.uri == uri), None)
465
+ readable = listed is not None
466
+ else:
467
+ readable = (owner is not None and uri == owner.uri) or any(
468
+ str(resource.uri) == uri for resource in client.resources
469
+ )
470
+ if readable:
471
+ result = await client.read_resource(uri)
472
+ finally:
473
+ await client.aclose()
474
+ except Exception as error:
475
+ return failed("read_resource", error)
476
+ if result is None:
477
+ if owner is not None and owner.resources != "dynamic":
478
+ return _error(
479
+ name,
480
+ f"{uri} is not a file of the {name} skill {owner.frontmatter['name']} as the "
481
+ f"application lists it. List its files with {name}__list_resources.",
482
+ uri=uri,
483
+ failure="not_listed",
484
+ )
485
+ return _error(
486
+ name,
487
+ f"The {name} application does not publish {uri}. List what it publishes with "
488
+ f"{name}__list_resources.",
489
+ uri=uri,
490
+ failure="not_published",
491
+ )
492
+ contents = result.contents
493
+ if listed is not None:
494
+ contents = [content for content in contents if str(content.uri) == uri]
495
+ if not _matches(contents, listed):
496
+ logger.warning(
497
+ "runtime.mcp_application.failed",
498
+ application=name,
499
+ operation="read_resource",
500
+ failure="unverified",
501
+ )
502
+ return _error(
503
+ name,
504
+ f"The {name} application served {uri} with other content than its skill "
505
+ f"lists, so it is not shown. List the skill again with {name}__list_resources.",
506
+ uri=uri,
507
+ failure="unverified",
508
+ )
509
+ origin = f"From the {name} application: {uri}"
510
+ details: dict[str, JSONValue] = {"application": name, "is_error": False, "uri": uri}
511
+ if owner is not None:
512
+ origin += f", a file of its skill {owner.frontmatter['name']}"
513
+ details |= {"skill": owner.uri, "verified": listed is not None}
514
+ return AgentToolResult(
515
+ content=[
516
+ TextContent(text=f"{origin}.\n\n"),
517
+ *(TextContent(text=_content_text(content)) for content in contents),
518
+ ],
519
+ details=details,
520
+ )
521
+
304
522
  async def call_tool(
305
523
  tool_call_id: str,
306
524
  arguments: Mapping[str, JSONValue],
@@ -381,6 +599,38 @@ def _application_tools(
381
599
  execute_fn=call_tool,
382
600
  execution_mode="sequential",
383
601
  ),
602
+ AgentTool(
603
+ name=f"{name}__list_resources",
604
+ label=f"List {name} skills and resources",
605
+ description=(
606
+ f"List the skills and resources the {name} application publishes. A skill is the "
607
+ "application's instructions for using its tools; each lists its files."
608
+ ),
609
+ parameters={"type": "object", "properties": {}, "additionalProperties": False},
610
+ execute_fn=list_resources,
611
+ execution_mode="parallel",
612
+ ),
613
+ AgentTool(
614
+ name=f"{name}__read_resource",
615
+ label=f"Read a {name} skill file or resource",
616
+ description=(
617
+ f"Read one skill file or resource the {name} application publishes, by its URI. "
618
+ f"Use {name}__list_resources first to see what it publishes."
619
+ ),
620
+ parameters={
621
+ "type": "object",
622
+ "properties": {
623
+ "uri": {
624
+ "type": "string",
625
+ "description": f"The URI of a {name} skill file or resource.",
626
+ },
627
+ },
628
+ "required": ["uri"],
629
+ "additionalProperties": False,
630
+ },
631
+ execute_fn=read_resource,
632
+ execution_mode="parallel",
633
+ ),
384
634
  ]
385
635
 
386
636
 
@@ -391,7 +641,7 @@ def create_mcp_application_tools(
391
641
  tool_timeout_seconds: float,
392
642
  open_timeout_seconds: float,
393
643
  ) -> list[AgentTool]:
394
- """Two tools for each declared application, in declaration order.
644
+ """Four tools for each declared application, in declaration order.
395
645
 
396
646
  ``tool_timeout_seconds`` bounds a call to a tool that declares no limit of its own;
397
647
  ``open_timeout_seconds`` is the wait for each request that opens the session.