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.
- mcp_yieldshell-1.1.2/README.md → mcp_yieldshell-1.1.3/PKG-INFO +12 -0
- mcp_yieldshell-1.1.2/PKG-INFO → mcp_yieldshell-1.1.3/README.md +4 -8
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/pyproject.toml +1 -1
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/server.py +20 -0
- mcp_yieldshell-1.1.3/tests/test_execute_description.py +89 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/test_protocol.py +21 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/.gitignore +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/__init__.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/__main__.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/config.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/execution/__init__.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/execution/containment.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/execution/managed.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/execution/manager.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/execution/ring_buffer.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/execution/spawn.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/policy.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/security.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/side_effects.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/src/mcp_yieldshell/types.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/__init__.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/test_config.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/test_integration.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/test_lifecycle_hardening.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/test_release.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/test_ring_buffer.py +0 -0
- {mcp_yieldshell-1.1.2 → mcp_yieldshell-1.1.3}/tests/test_security.py +0 -0
- {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=,`.
|
|
@@ -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
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|