mcp-yieldshell 1.1.2__tar.gz → 1.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. mcp_yieldshell-1.1.2/README.md → mcp_yieldshell-1.1.3/PKG-INFO +12 -0
  2. mcp_yieldshell-1.1.2/PKG-INFO → mcp_yieldshell-1.1.3/README.md +4 -8
  3. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/pyproject.toml +1 -1
  4. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/server.py +20 -0
  5. mcp_yieldshell-1.1.3/tests/test_execute_description.py +89 -0
  6. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/test_protocol.py +21 -0
  7. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/.gitignore +0 -0
  8. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/__init__.py +0 -0
  9. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/__main__.py +0 -0
  10. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/config.py +0 -0
  11. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/execution/__init__.py +0 -0
  12. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/execution/containment.py +0 -0
  13. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/execution/managed.py +0 -0
  14. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/execution/manager.py +0 -0
  15. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/execution/ring_buffer.py +0 -0
  16. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/execution/spawn.py +0 -0
  17. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/policy.py +0 -0
  18. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/security.py +0 -0
  19. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/side_effects.py +0 -0
  20. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/types.py +0 -0
  21. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/__init__.py +0 -0
  22. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/test_config.py +0 -0
  23. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/test_integration.py +0 -0
  24. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/test_lifecycle_hardening.py +0 -0
  25. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/test_release.py +0 -0
  26. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/test_ring_buffer.py +0 -0
  27. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/test_security.py +0 -0
  28. {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/test_side_effects.py +0 -0
@@ -1,3 +1,11 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-yieldshell
3
+ Version: 1.1.3
4
+ Summary: A drop-in shell MCP that auto-yields long-running commands into managed background executions.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: mcp<2,>=1.9.0
7
+ Description-Content-Type: text/markdown
8
+
1
9
  # YieldShell MCP
2
10
 
3
11
  A drop-in shell MCP server that auto-yields long-running commands into managed
@@ -147,8 +155,12 @@ to the background. Addressable responses include an opaque `execution_id`;
147
155
  clients must pass it through unchanged to lifecycle tools. `process_id` is the
148
156
  numeric OS PID of the initial shell and is not a lifecycle handle.
149
157
 
158
+ Every call must now supply `description`. Missing, null, non-string, empty,
159
+ or whitespace-only descriptions fail MCP argument validation before execution.
160
+
150
161
  * **Parameters**:
151
162
  * `command` (string, **required**): The command string to execute in the shell.
163
+ * `description` (string, **required**): A concise, non-blank explanation of what the command does, for example `"Run the project test suite"`. It appears only in the tool call; it is not saved as execution metadata or included in responses or `ps` results. There is no fixed length limit.
152
164
  * `side_effects` (array of string, **required**): The side-effect categories this command may plausibly have. Must contain at least one entry drawn from the enum below. Use `["NONE"]` for commands with no meaningful side effects. `NONE` is exclusive and must not be combined with any other category. The server rejects the call with `failed_to_start` if any declared category is configured as blocked.
153
165
  * Allowed values: `CHANGES_NETWORK_CONFIGURATION`, `CHANGES_PACKAGES_OR_DEPENDENCIES`, `CONSUMES_SIGNIFICANT_RESOURCES`, `DELETES_FILES`, `EXPOSES_SECRETS`, `KILLS_AGENT_PROCESS`, `MAKES_NETWORK_REQUESTS`, `MODIFIES_OS_SETTINGS`, `MODIFIES_OS_USER_SETTINGS`, `MODIFIES_OUTSIDE_WORKSPACE`, `MODIFIES_PRODUCTION_SERVICES`, `MODIFIES_PROTECTED_FILES`, `MODIFIES_SECURITY_CONTROLS`, `MODIFIES_WORKSPACE_FILES`, `NONE`, `OTHER`, `RUNS_INLINE_CODE`, `RUNS_PRIVILEGED_COMMANDS`, `STOPS_OR_RESTARTS_SERVICES`, `UNKNOWN`, `USES_DESTRUCTIVE_GIT_OPERATION`.
154
166
  * `RUNS_INLINE_CODE` is in the default blocklist. It covers commands that execute code supplied inline to an interpreter or shell (e.g. `python -c`, `node -e`, `curl ... | sh`). It does not cover simply creating a script or executable file unless the same command also executes inline code. The safer next action is to write the content to a reviewable workspace file and execute it in a small, inspectable step. Operators can clear the blocklist via `MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS=,`.
@@ -1,11 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: mcp-yieldshell
3
- Version: 1.1.2
4
- Summary: A drop-in shell MCP that auto-yields long-running commands into managed background executions.
5
- Requires-Python: >=3.11
6
- Requires-Dist: mcp<2,>=1.9.0
7
- Description-Content-Type: text/markdown
8
-
9
1
  # YieldShell MCP
10
2
 
11
3
  A drop-in shell MCP server that auto-yields long-running commands into managed
@@ -155,8 +147,12 @@ to the background. Addressable responses include an opaque `execution_id`;
155
147
  clients must pass it through unchanged to lifecycle tools. `process_id` is the
156
148
  numeric OS PID of the initial shell and is not a lifecycle handle.
157
149
 
150
+ Every call must now supply `description`. Missing, null, non-string, empty,
151
+ or whitespace-only descriptions fail MCP argument validation before execution.
152
+
158
153
  * **Parameters**:
159
154
  * `command` (string, **required**): The command string to execute in the shell.
155
+ * `description` (string, **required**): A concise, non-blank explanation of what the command does, for example `"Run the project test suite"`. It appears only in the tool call; it is not saved as execution metadata or included in responses or `ps` results. There is no fixed length limit.
160
156
  * `side_effects` (array of string, **required**): The side-effect categories this command may plausibly have. Must contain at least one entry drawn from the enum below. Use `["NONE"]` for commands with no meaningful side effects. `NONE` is exclusive and must not be combined with any other category. The server rejects the call with `failed_to_start` if any declared category is configured as blocked.
161
157
  * Allowed values: `CHANGES_NETWORK_CONFIGURATION`, `CHANGES_PACKAGES_OR_DEPENDENCIES`, `CONSUMES_SIGNIFICANT_RESOURCES`, `DELETES_FILES`, `EXPOSES_SECRETS`, `KILLS_AGENT_PROCESS`, `MAKES_NETWORK_REQUESTS`, `MODIFIES_OS_SETTINGS`, `MODIFIES_OS_USER_SETTINGS`, `MODIFIES_OUTSIDE_WORKSPACE`, `MODIFIES_PRODUCTION_SERVICES`, `MODIFIES_PROTECTED_FILES`, `MODIFIES_SECURITY_CONTROLS`, `MODIFIES_WORKSPACE_FILES`, `NONE`, `OTHER`, `RUNS_INLINE_CODE`, `RUNS_PRIVILEGED_COMMANDS`, `STOPS_OR_RESTARTS_SERVICES`, `UNKNOWN`, `USES_DESTRUCTIVE_GIT_OPERATION`.
162
158
  * `RUNS_INLINE_CODE` is in the default blocklist. It covers commands that execute code supplied inline to an interpreter or shell (e.g. `python -c`, `node -e`, `curl ... | sh`). It does not cover simply creating a script or executable file unless the same command also executes inline code. The safer next action is to write the content to a reviewable workspace file and execute it in a small, inspectable step. Operators can clear the blocklist via `MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS=,`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-yieldshell"
3
- version = "1.1.2"
3
+ version = "1.1.3"
4
4
  description = "A drop-in shell MCP that auto-yields long-running commands into managed background executions."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -4,8 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  from collections.abc import AsyncIterator
6
6
  from contextlib import asynccontextmanager
7
+ from typing import Annotated
7
8
 
8
9
  from mcp.server.fastmcp import FastMCP
10
+ from pydantic import Field
9
11
 
10
12
  from .config import Config
11
13
  from .execution.manager import ExecutionManager
@@ -39,6 +41,18 @@ def _get_manager() -> ExecutionManager:
39
41
  async def execute(
40
42
  command: str,
41
43
  side_effects: list[SideEffect],
44
+ description: Annotated[
45
+ str,
46
+ Field(
47
+ strict=True,
48
+ min_length=1,
49
+ pattern=r"\S",
50
+ description=(
51
+ "A concise explanation of what the command does, "
52
+ "for example: Run the project test suite. Must not be blank."
53
+ ),
54
+ ),
55
+ ],
42
56
  cwd: str | None = None,
43
57
  env: dict[str, str] | None = None,
44
58
  shell: str | None = None,
@@ -55,6 +69,12 @@ async def execute(
55
69
  through unchanged to ``read``, ``write``, ``wait``, or ``stop``; never
56
70
  parse it or substitute the numeric OS ``process_id``.
57
71
 
72
+ ``description`` is required and must be a non-blank string explaining
73
+ what the command does, for example ``"Run the project test suite"``.
74
+ It appears only in the tool call and is not retained as execution
75
+ metadata or included in responses. Invalid descriptions are rejected
76
+ by MCP argument validation before the execution manager is invoked.
77
+
58
78
  ``side_effects`` is required and must be a non-empty list. Declare every
59
79
  plausible side-effect category before running the command. ``NONE`` is
60
80
  exclusive and must not be combined with any other category; pass
@@ -0,0 +1,89 @@
1
+ """MCP schema and validation checks for the required execution description."""
2
+
3
+ from unittest.mock import AsyncMock, Mock
4
+
5
+ import pytest
6
+ from mcp.server.fastmcp.exceptions import ToolError
7
+ from mcp.server.fastmcp.tools.base import Tool
8
+
9
+ from mcp_yieldshell import server
10
+
11
+
12
+ @pytest.fixture
13
+ def execute_tool():
14
+ return Tool.from_function(server.execute)
15
+
16
+
17
+ def test_description_schema_is_required_and_explains_its_purpose(execute_tool):
18
+ schema = execute_tool.parameters
19
+ assert "description" in schema["required"]
20
+ description = schema["properties"]["description"]
21
+ assert description["type"] == "string"
22
+ assert description["minLength"] == 1
23
+ assert description["pattern"] == r"\S"
24
+ assert "what the command does" in description["description"]
25
+ assert "default" not in description
26
+ assert "maxLength" not in description
27
+
28
+
29
+ @pytest.mark.parametrize(
30
+ "description_args",
31
+ [
32
+ pytest.param({}, id="missing"),
33
+ pytest.param({"description": None}, id="null"),
34
+ pytest.param({"description": 123}, id="integer"),
35
+ pytest.param({"description": 1.5}, id="float"),
36
+ pytest.param({"description": True}, id="boolean"),
37
+ pytest.param({"description": []}, id="array"),
38
+ pytest.param({"description": {}}, id="object"),
39
+ pytest.param({"description": ""}, id="empty"),
40
+ pytest.param({"description": " \t\r\n"}, id="whitespace"),
41
+ pytest.param({"description": "\u2003\u00a0"}, id="unicode-whitespace"),
42
+ ],
43
+ )
44
+ async def test_invalid_description_never_reaches_manager(
45
+ execute_tool, monkeypatch, description_args
46
+ ):
47
+ get_manager = Mock()
48
+ monkeypatch.setattr(server, "_get_manager", get_manager)
49
+
50
+ with pytest.raises(ToolError, match="description"):
51
+ await execute_tool.run(
52
+ {"command": "echo hello", "side_effects": ["NONE"], **description_args}
53
+ )
54
+
55
+ get_manager.assert_not_called()
56
+
57
+
58
+ @pytest.mark.parametrize(
59
+ "description",
60
+ [
61
+ "Print a greeting",
62
+ " \n输出问候语\t ",
63
+ "Explain the command. " * 1_000,
64
+ ],
65
+ ids=["concise", "unicode-with-whitespace", "long"],
66
+ )
67
+ async def test_valid_description_is_not_forwarded_to_manager(
68
+ execute_tool, monkeypatch, description
69
+ ):
70
+ manager = Mock()
71
+ response = {"status": "completed", "stdout": "hello\n"}
72
+ manager.execute_command = AsyncMock(return_value=response)
73
+ monkeypatch.setattr(server, "_get_manager", lambda: manager)
74
+
75
+ result = await execute_tool.run(
76
+ {
77
+ "command": "echo hello",
78
+ "side_effects": ["NONE"],
79
+ "description": description,
80
+ "name": "greeting",
81
+ }
82
+ )
83
+
84
+ assert result == response
85
+ manager.execute_command.assert_awaited_once()
86
+ arguments = manager.execute_command.await_args.kwargs
87
+ assert arguments["command"] == "echo hello"
88
+ assert arguments["name"] == "greeting"
89
+ assert "description" not in arguments
@@ -49,6 +49,9 @@ async def test_stdio_server_lists_and_executes_tools():
49
49
  "write",
50
50
  }
51
51
  assert "exec" not in tool_names
52
+ execute_schema = tools_by_name["execute"].inputSchema
53
+ assert "description" in execute_schema["required"]
54
+ assert execute_schema["properties"]["description"]["type"] == "string"
52
55
  for tool_name in ("read", "write", "wait", "stop"):
53
56
  tool = tools_by_name[tool_name]
54
57
  properties = tool.inputSchema["properties"]
@@ -59,11 +62,21 @@ async def test_stdio_server_lists_and_executes_tools():
59
62
  assert "opaque" in (tool.description or "")
60
63
  assert "passed through unchanged" in (tool.description or "")
61
64
 
65
+ missing_description = await session.call_tool(
66
+ "execute",
67
+ {"command": "printf should-not-run", "side_effects": ["NONE"]},
68
+ )
69
+ assert missing_description.isError is True
70
+ assert isinstance(missing_description.content[0], TextContent)
71
+ assert "description" in missing_description.content[0].text
72
+ assert _payload(await session.call_tool("ps", {}))["executions"] == []
73
+
62
74
  result = await session.call_tool(
63
75
  "execute",
64
76
  {
65
77
  "command": "printf protocol-ok",
66
78
  "side_effects": ["NONE"],
79
+ "description": "Print a marker to verify command execution",
67
80
  },
68
81
  )
69
82
 
@@ -73,6 +86,11 @@ async def test_stdio_server_lists_and_executes_tools():
73
86
  assert isinstance(payload["execution_id"], str)
74
87
  assert isinstance(payload["process_id"], int)
75
88
  assert "pid" not in payload
89
+ assert "description" not in payload
90
+ listing = _payload(await session.call_tool("ps", {}))
91
+ entry = listing["executions"][0]
92
+ assert entry["execution_id"] == payload["execution_id"]
93
+ assert "description" not in entry
76
94
 
77
95
  rejected = await session.call_tool(
78
96
  "read",
@@ -102,6 +120,7 @@ async def test_stdio_server_incremental_polling_does_not_replay_output():
102
120
  "sleep 0.2; done"
103
121
  ),
104
122
  "side_effects": ["NONE"],
123
+ "description": "Emit numbered chunks to verify incremental polling",
105
124
  "yield_ms": 0,
106
125
  },
107
126
  )
@@ -172,6 +191,7 @@ async def test_stdio_server_background_input_wait_stop_and_cleanup_workflow():
172
191
  {
173
192
  "command": "cat",
174
193
  "side_effects": ["NONE"],
194
+ "description": "Echo input to verify interactive execution",
175
195
  "close_stdin": False,
176
196
  "yield_ms": 0,
177
197
  },
@@ -213,6 +233,7 @@ async def test_stdio_server_background_input_wait_stop_and_cleanup_workflow():
213
233
  {
214
234
  "command": "sleep 30",
215
235
  "side_effects": ["NONE"],
236
+ "description": "Start a background process to verify stopping it",
216
237
  "yield_ms": 0,
217
238
  },
218
239
  )