hidden-moves-mcp 0.1.0__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.
- hidden_moves_mcp-0.1.0/LICENSE +21 -0
- hidden_moves_mcp-0.1.0/PKG-INFO +181 -0
- hidden_moves_mcp-0.1.0/README.md +157 -0
- hidden_moves_mcp-0.1.0/pyproject.toml +45 -0
- hidden_moves_mcp-0.1.0/pyproject.toml.orig +36 -0
- hidden_moves_mcp-0.1.0/src/hidden_moves_mcp/__init__.py +6 -0
- hidden_moves_mcp-0.1.0/src/hidden_moves_mcp/__main__.py +6 -0
- hidden_moves_mcp-0.1.0/src/hidden_moves_mcp/cli.py +31 -0
- hidden_moves_mcp-0.1.0/src/hidden_moves_mcp/http.py +166 -0
- hidden_moves_mcp-0.1.0/src/hidden_moves_mcp/server.py +150 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ella Inng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hidden-moves-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Expose selected Python capabilities through the Model Context Protocol.
|
|
5
|
+
Keywords: mcp,tools,capabilities,python
|
|
6
|
+
Author: Ella Inng
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Dist: hidden-moves>=0.1,<0.2
|
|
16
|
+
Requires-Dist: mcp>=2.3.0,<3
|
|
17
|
+
Maintainer: Outside Labs
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Project-URL: Homepage, https://github.com/outside-labs/hidden_moves
|
|
20
|
+
Project-URL: Source, https://github.com/outside-labs/hidden_moves
|
|
21
|
+
Project-URL: Issues, https://github.com/outside-labs/hidden_moves/issues
|
|
22
|
+
Project-URL: Documentation, https://github.com/outside-labs/hidden_moves/blob/main/packages/hidden-moves-mcp/README.md
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# Hidden Moves MCP adapter
|
|
26
|
+
|
|
27
|
+
An optional adapter around explicitly selected capability catalogs. The MCP SDK
|
|
28
|
+
dependency belongs to this distribution; the core registry remains independent.
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
python -m pip install hidden-moves-mcp==0.1.0
|
|
32
|
+
hidden-moves-mcp --help
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The experimental 0.1.x adapter depends on `hidden-moves>=0.1,<0.2` and Python
|
|
36
|
+
3.11 or newer. Published GitHub releases use `v0.1.0-mcp` for this package in the
|
|
37
|
+
shared repository. `.github/workflows/release.yaml` builds only its distribution,
|
|
38
|
+
checks installed-wheel transports, and publishes in environment `pypi-mcp`.
|
|
39
|
+
|
|
40
|
+
## Local installation
|
|
41
|
+
|
|
42
|
+
From the repository root:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
uv venv /tmp/hidden-moves-mcp-env
|
|
46
|
+
uv pip install --python /tmp/hidden-moves-mcp-env/bin/python \
|
|
47
|
+
. ./examples/text-plugin ./packages/hidden-moves-mcp
|
|
48
|
+
/tmp/hidden-moves-mcp-env/bin/python -m unittest discover \
|
|
49
|
+
-s packages/hidden-moves-mcp/tests -v
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The adapter uses the official Python SDK, `mcp>=2.3.0,<3`. The local proof was
|
|
53
|
+
verified with SDK 2.3.0 and protocol `2026-07-28`; legacy clients can negotiate
|
|
54
|
+
the SDK's supported `2025-11-25` handshake. See the official
|
|
55
|
+
[protocol-version documentation](https://py.sdk.modelcontextprotocol.io/protocol-versions/).
|
|
56
|
+
|
|
57
|
+
## Configure a local stdio host
|
|
58
|
+
|
|
59
|
+
A host starts the installed executable and owns the subprocess:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"command": "/tmp/hidden-moves-mcp-env/bin/hidden-moves-mcp",
|
|
64
|
+
"args": [
|
|
65
|
+
"--plugin", "example-text",
|
|
66
|
+
"--move", "example.text.repeat"
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The `--move` option is required and repeatable. Only those names are listed and
|
|
72
|
+
callable. Providers must be explicitly enabled with `--plugin`; installing one
|
|
73
|
+
does not activate it. The executable begins with an empty registry and supplies
|
|
74
|
+
no target binding. Configure a Python host for target-bound providers. It waits for protocol input on
|
|
75
|
+
stdin and exits when its host closes the connection. Output on stdout is the
|
|
76
|
+
protocol stream; application logging belongs on stderr.
|
|
77
|
+
|
|
78
|
+
This is an executable local proof, without a public service or configured account.
|
|
79
|
+
The temporary environment is suitable for testing; install into a durable location
|
|
80
|
+
before configuring a host for regular use.
|
|
81
|
+
|
|
82
|
+
## Python setup and binding
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
import asyncio
|
|
86
|
+
|
|
87
|
+
from hidden_moves import Moves
|
|
88
|
+
from hidden_moves.adapters import CapabilityCatalog
|
|
89
|
+
from hidden_moves_example_text import repeat_text
|
|
90
|
+
from hidden_moves_mcp import MCPAdapter, serve_stdio
|
|
91
|
+
|
|
92
|
+
moves = Moves()
|
|
93
|
+
moves.learn(repeat_text, name="repeat", namespace="example.text")
|
|
94
|
+
catalog = CapabilityCatalog(moves, ["example.text.repeat"])
|
|
95
|
+
server = MCPAdapter(catalog).server()
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
asyncio.run(serve_stdio(server))
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Applications can configure clients or targets before building the catalog. The
|
|
102
|
+
adapter consumes those bound callables and does not infer credentials or context.
|
|
103
|
+
The stdio adapter defaults to invoking synchronous functions in the request
|
|
104
|
+
handler and awaiting awaitable results there. `MCPAdapter` also accepts explicit
|
|
105
|
+
`offload_sync=True` and a finite `call_timeout` for hosts that need them. Client
|
|
106
|
+
resource setup and shutdown belong to the application.
|
|
107
|
+
|
|
108
|
+
## Local Streamable HTTP
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from hidden_moves_mcp import create_http_app, serve_http
|
|
112
|
+
|
|
113
|
+
async def authorize(request):
|
|
114
|
+
# Check the application's local request grant without consuming the body.
|
|
115
|
+
return await local_access_policy(request.headers)
|
|
116
|
+
|
|
117
|
+
app = create_http_app(catalog, authorize=authorize)
|
|
118
|
+
asyncio.run(serve_http(app, port=8000))
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The application supplies an async authorizer that returns exactly `True` for each
|
|
122
|
+
allowed request. Missing grants, rejected grants and authorizer exceptions return
|
|
123
|
+
401 before protocol dispatch. It chooses credentials, request identity and the
|
|
124
|
+
selected catalog; no installed provider or account is activated implicitly.
|
|
125
|
+
|
|
126
|
+
`serve_http` binds only `127.0.0.1`. The
|
|
127
|
+
[SDK's ASGI app](https://py.sdk.modelcontextprotocol.io/run/asgi/) handles
|
|
128
|
+
Streamable HTTP, initialization, protocol negotiation and lifespan cleanup at
|
|
129
|
+
`/mcp`, with its default localhost Host/Origin protection. Responses are JSON and
|
|
130
|
+
HTTP is stateless. The same adapter supplies schemas, annotations and
|
|
131
|
+
`structuredContent: {"result": value}` over HTTP and stdio. Unselected names remain
|
|
132
|
+
protocol errors. Mounting the returned app inside another application requires
|
|
133
|
+
the host to run its lifespan, as described in the SDK guide.
|
|
134
|
+
|
|
135
|
+
Defaults are a 10-second call deadline, 30-second request deadline, 16 concurrent
|
|
136
|
+
requests and a 1 MiB request body. Limits must be finite; excess concurrency returns
|
|
137
|
+
503, an expired request returns 504, and the SDK rejects oversized bodies with 413.
|
|
138
|
+
Timed-out tools return sanitized tool errors. Access logging is disabled by the
|
|
139
|
+
local serving helper, and forwarded identity headers are not trusted.
|
|
140
|
+
|
|
141
|
+
HTTP offloads synchronous capabilities by default so blocking I/O does not stop
|
|
142
|
+
other requests. Async capabilities execute on the event loop. Configure the
|
|
143
|
+
underlying clients with their own finite I/O timeouts: cancellation cannot stop a
|
|
144
|
+
running Python worker thread, and a timed-out write may still complete. Inspect
|
|
145
|
+
its outcome before submitting another write. Thread-affine resources need an
|
|
146
|
+
application-owned execution strategy; `offload_sync=False` is available for fast
|
|
147
|
+
event-loop-safe callables, whose deadlines depend on cooperative yielding. The
|
|
148
|
+
host owns resource cleanup and concurrency safety.
|
|
149
|
+
|
|
150
|
+
`examples/mcp_http.py` serves only `example.text.repeat` using an explicitly
|
|
151
|
+
configured `HIDDEN_MOVES_LOCAL_HTTP_TOKEN` of 32–512 printable ASCII characters.
|
|
152
|
+
It demonstrates a local request grant; hosted OAuth, delegated GitHub credentials
|
|
153
|
+
and per-user catalog isolation require their separate authentication work.
|
|
154
|
+
|
|
155
|
+
Installed-wheel tests run real loopback initialize/list/call clients in modern
|
|
156
|
+
and legacy modes, compare Python/HTTP DTOs, check authorization and request limits,
|
|
157
|
+
exercise cancellation and worker offloading, then shut down the SDK lifespan and
|
|
158
|
+
listener. The existing real stdio subprocess checks still run in the same suite.
|
|
159
|
+
|
|
160
|
+
## Export contract
|
|
161
|
+
|
|
162
|
+
MCP names retain qualified capability names when they satisfy the protocol's ASCII
|
|
163
|
+
name rules. Explicit `tool_names={qualified_name: alias}` mappings handle other
|
|
164
|
+
names; invalid or colliding aliases fail before server creation. Exported schemas
|
|
165
|
+
are independent copies of neutral definitions.
|
|
166
|
+
|
|
167
|
+
All successful results use `structuredContent: {"result": value}` with the matching
|
|
168
|
+
object output schema and an equivalent JSON text block. This includes object,
|
|
169
|
+
scalar, list, and null results. The catalog validates and serializes the underlying
|
|
170
|
+
value before wrapping it. Ordinary Python results remain unchanged outside this
|
|
171
|
+
adapter.
|
|
172
|
+
|
|
173
|
+
Behavioral hints map explicitly to MCP annotations, with unknown hints omitted.
|
|
174
|
+
They do not authorize calls. The host/application chooses exposure and approval
|
|
175
|
+
policy. Invalid arguments and invalid results are tool errors. Capability failures
|
|
176
|
+
are tool errors reporting the exception type, without exposing arbitrary exception
|
|
177
|
+
contents. Unknown/unexposed tool names are protocol errors. Cancellation propagates.
|
|
178
|
+
|
|
179
|
+
The [low-level SDK server](https://py.sdk.modelcontextprotocol.io/advanced/low-level-server/)
|
|
180
|
+
handles protocol discovery, legacy negotiation, wire types, and connection lifetime;
|
|
181
|
+
the adapter owns its list/call handlers and uses the catalog for validation.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Hidden Moves MCP adapter
|
|
2
|
+
|
|
3
|
+
An optional adapter around explicitly selected capability catalogs. The MCP SDK
|
|
4
|
+
dependency belongs to this distribution; the core registry remains independent.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
python -m pip install hidden-moves-mcp==0.1.0
|
|
8
|
+
hidden-moves-mcp --help
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The experimental 0.1.x adapter depends on `hidden-moves>=0.1,<0.2` and Python
|
|
12
|
+
3.11 or newer. Published GitHub releases use `v0.1.0-mcp` for this package in the
|
|
13
|
+
shared repository. `.github/workflows/release.yaml` builds only its distribution,
|
|
14
|
+
checks installed-wheel transports, and publishes in environment `pypi-mcp`.
|
|
15
|
+
|
|
16
|
+
## Local installation
|
|
17
|
+
|
|
18
|
+
From the repository root:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
uv venv /tmp/hidden-moves-mcp-env
|
|
22
|
+
uv pip install --python /tmp/hidden-moves-mcp-env/bin/python \
|
|
23
|
+
. ./examples/text-plugin ./packages/hidden-moves-mcp
|
|
24
|
+
/tmp/hidden-moves-mcp-env/bin/python -m unittest discover \
|
|
25
|
+
-s packages/hidden-moves-mcp/tests -v
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The adapter uses the official Python SDK, `mcp>=2.3.0,<3`. The local proof was
|
|
29
|
+
verified with SDK 2.3.0 and protocol `2026-07-28`; legacy clients can negotiate
|
|
30
|
+
the SDK's supported `2025-11-25` handshake. See the official
|
|
31
|
+
[protocol-version documentation](https://py.sdk.modelcontextprotocol.io/protocol-versions/).
|
|
32
|
+
|
|
33
|
+
## Configure a local stdio host
|
|
34
|
+
|
|
35
|
+
A host starts the installed executable and owns the subprocess:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"command": "/tmp/hidden-moves-mcp-env/bin/hidden-moves-mcp",
|
|
40
|
+
"args": [
|
|
41
|
+
"--plugin", "example-text",
|
|
42
|
+
"--move", "example.text.repeat"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The `--move` option is required and repeatable. Only those names are listed and
|
|
48
|
+
callable. Providers must be explicitly enabled with `--plugin`; installing one
|
|
49
|
+
does not activate it. The executable begins with an empty registry and supplies
|
|
50
|
+
no target binding. Configure a Python host for target-bound providers. It waits for protocol input on
|
|
51
|
+
stdin and exits when its host closes the connection. Output on stdout is the
|
|
52
|
+
protocol stream; application logging belongs on stderr.
|
|
53
|
+
|
|
54
|
+
This is an executable local proof, without a public service or configured account.
|
|
55
|
+
The temporary environment is suitable for testing; install into a durable location
|
|
56
|
+
before configuring a host for regular use.
|
|
57
|
+
|
|
58
|
+
## Python setup and binding
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import asyncio
|
|
62
|
+
|
|
63
|
+
from hidden_moves import Moves
|
|
64
|
+
from hidden_moves.adapters import CapabilityCatalog
|
|
65
|
+
from hidden_moves_example_text import repeat_text
|
|
66
|
+
from hidden_moves_mcp import MCPAdapter, serve_stdio
|
|
67
|
+
|
|
68
|
+
moves = Moves()
|
|
69
|
+
moves.learn(repeat_text, name="repeat", namespace="example.text")
|
|
70
|
+
catalog = CapabilityCatalog(moves, ["example.text.repeat"])
|
|
71
|
+
server = MCPAdapter(catalog).server()
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
asyncio.run(serve_stdio(server))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Applications can configure clients or targets before building the catalog. The
|
|
78
|
+
adapter consumes those bound callables and does not infer credentials or context.
|
|
79
|
+
The stdio adapter defaults to invoking synchronous functions in the request
|
|
80
|
+
handler and awaiting awaitable results there. `MCPAdapter` also accepts explicit
|
|
81
|
+
`offload_sync=True` and a finite `call_timeout` for hosts that need them. Client
|
|
82
|
+
resource setup and shutdown belong to the application.
|
|
83
|
+
|
|
84
|
+
## Local Streamable HTTP
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from hidden_moves_mcp import create_http_app, serve_http
|
|
88
|
+
|
|
89
|
+
async def authorize(request):
|
|
90
|
+
# Check the application's local request grant without consuming the body.
|
|
91
|
+
return await local_access_policy(request.headers)
|
|
92
|
+
|
|
93
|
+
app = create_http_app(catalog, authorize=authorize)
|
|
94
|
+
asyncio.run(serve_http(app, port=8000))
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The application supplies an async authorizer that returns exactly `True` for each
|
|
98
|
+
allowed request. Missing grants, rejected grants and authorizer exceptions return
|
|
99
|
+
401 before protocol dispatch. It chooses credentials, request identity and the
|
|
100
|
+
selected catalog; no installed provider or account is activated implicitly.
|
|
101
|
+
|
|
102
|
+
`serve_http` binds only `127.0.0.1`. The
|
|
103
|
+
[SDK's ASGI app](https://py.sdk.modelcontextprotocol.io/run/asgi/) handles
|
|
104
|
+
Streamable HTTP, initialization, protocol negotiation and lifespan cleanup at
|
|
105
|
+
`/mcp`, with its default localhost Host/Origin protection. Responses are JSON and
|
|
106
|
+
HTTP is stateless. The same adapter supplies schemas, annotations and
|
|
107
|
+
`structuredContent: {"result": value}` over HTTP and stdio. Unselected names remain
|
|
108
|
+
protocol errors. Mounting the returned app inside another application requires
|
|
109
|
+
the host to run its lifespan, as described in the SDK guide.
|
|
110
|
+
|
|
111
|
+
Defaults are a 10-second call deadline, 30-second request deadline, 16 concurrent
|
|
112
|
+
requests and a 1 MiB request body. Limits must be finite; excess concurrency returns
|
|
113
|
+
503, an expired request returns 504, and the SDK rejects oversized bodies with 413.
|
|
114
|
+
Timed-out tools return sanitized tool errors. Access logging is disabled by the
|
|
115
|
+
local serving helper, and forwarded identity headers are not trusted.
|
|
116
|
+
|
|
117
|
+
HTTP offloads synchronous capabilities by default so blocking I/O does not stop
|
|
118
|
+
other requests. Async capabilities execute on the event loop. Configure the
|
|
119
|
+
underlying clients with their own finite I/O timeouts: cancellation cannot stop a
|
|
120
|
+
running Python worker thread, and a timed-out write may still complete. Inspect
|
|
121
|
+
its outcome before submitting another write. Thread-affine resources need an
|
|
122
|
+
application-owned execution strategy; `offload_sync=False` is available for fast
|
|
123
|
+
event-loop-safe callables, whose deadlines depend on cooperative yielding. The
|
|
124
|
+
host owns resource cleanup and concurrency safety.
|
|
125
|
+
|
|
126
|
+
`examples/mcp_http.py` serves only `example.text.repeat` using an explicitly
|
|
127
|
+
configured `HIDDEN_MOVES_LOCAL_HTTP_TOKEN` of 32–512 printable ASCII characters.
|
|
128
|
+
It demonstrates a local request grant; hosted OAuth, delegated GitHub credentials
|
|
129
|
+
and per-user catalog isolation require their separate authentication work.
|
|
130
|
+
|
|
131
|
+
Installed-wheel tests run real loopback initialize/list/call clients in modern
|
|
132
|
+
and legacy modes, compare Python/HTTP DTOs, check authorization and request limits,
|
|
133
|
+
exercise cancellation and worker offloading, then shut down the SDK lifespan and
|
|
134
|
+
listener. The existing real stdio subprocess checks still run in the same suite.
|
|
135
|
+
|
|
136
|
+
## Export contract
|
|
137
|
+
|
|
138
|
+
MCP names retain qualified capability names when they satisfy the protocol's ASCII
|
|
139
|
+
name rules. Explicit `tool_names={qualified_name: alias}` mappings handle other
|
|
140
|
+
names; invalid or colliding aliases fail before server creation. Exported schemas
|
|
141
|
+
are independent copies of neutral definitions.
|
|
142
|
+
|
|
143
|
+
All successful results use `structuredContent: {"result": value}` with the matching
|
|
144
|
+
object output schema and an equivalent JSON text block. This includes object,
|
|
145
|
+
scalar, list, and null results. The catalog validates and serializes the underlying
|
|
146
|
+
value before wrapping it. Ordinary Python results remain unchanged outside this
|
|
147
|
+
adapter.
|
|
148
|
+
|
|
149
|
+
Behavioral hints map explicitly to MCP annotations, with unknown hints omitted.
|
|
150
|
+
They do not authorize calls. The host/application chooses exposure and approval
|
|
151
|
+
policy. Invalid arguments and invalid results are tool errors. Capability failures
|
|
152
|
+
are tool errors reporting the exception type, without exposing arbitrary exception
|
|
153
|
+
contents. Unknown/unexposed tool names are protocol errors. Cancellation propagates.
|
|
154
|
+
|
|
155
|
+
The [low-level SDK server](https://py.sdk.modelcontextprotocol.io/advanced/low-level-server/)
|
|
156
|
+
handles protocol discovery, legacy negotiation, wire types, and connection lifetime;
|
|
157
|
+
the adapter owns its list/call handlers and uses the catalog for validation.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hidden-moves-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Expose selected Python capabilities through the Model Context Protocol."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"mcp",
|
|
11
|
+
"tools",
|
|
12
|
+
"capabilities",
|
|
13
|
+
"python",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"hidden-moves>=0.1,<0.2",
|
|
25
|
+
"mcp>=2.3.0,<3",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[[project.authors]]
|
|
29
|
+
name = "Ella Inng"
|
|
30
|
+
|
|
31
|
+
[[project.maintainers]]
|
|
32
|
+
name = "Outside Labs"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
hidden-moves-mcp = "hidden_moves_mcp.cli:main"
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/outside-labs/hidden_moves"
|
|
39
|
+
Source = "https://github.com/outside-labs/hidden_moves"
|
|
40
|
+
Issues = "https://github.com/outside-labs/hidden_moves/issues"
|
|
41
|
+
Documentation = "https://github.com/outside-labs/hidden_moves/blob/main/packages/hidden-moves-mcp/README.md"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.12.1,<0.13.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hidden-moves-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Expose selected Python capabilities through the Model Context Protocol."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Ella Inng" }]
|
|
10
|
+
maintainers = [{ name = "Outside Labs" }]
|
|
11
|
+
keywords = ["mcp", "tools", "capabilities", "python"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"hidden-moves>=0.1,<0.2",
|
|
22
|
+
"mcp>=2.3.0,<3",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
hidden-moves-mcp = "hidden_moves_mcp.cli:main"
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/outside-labs/hidden_moves"
|
|
30
|
+
Source = "https://github.com/outside-labs/hidden_moves"
|
|
31
|
+
Issues = "https://github.com/outside-labs/hidden_moves/issues"
|
|
32
|
+
Documentation = "https://github.com/outside-labs/hidden_moves/blob/main/packages/hidden-moves-mcp/README.md"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["uv_build>=0.12.1,<0.13.0"]
|
|
36
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Explicit provider activation and capability selection for a local stdio host."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
|
|
6
|
+
from hidden_moves import MoveError, Moves, Registry, discover_providers, load_provider
|
|
7
|
+
from hidden_moves.adapters import CapabilityCatalog
|
|
8
|
+
|
|
9
|
+
from .server import MCPAdapter, serve_stdio
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
parser = argparse.ArgumentParser(description="Serve explicitly selected Hidden Moves capabilities over MCP stdio.")
|
|
14
|
+
parser.add_argument("--move", dest="moves", action="append", required=True, help="Qualified capability to expose; repeat for multiple names.")
|
|
15
|
+
parser.add_argument("--plugin", action="append", default=[], help="Installed provider to explicitly activate.")
|
|
16
|
+
parser.add_argument("--name", default="hidden-moves", help="Server identity.")
|
|
17
|
+
arguments = parser.parse_args()
|
|
18
|
+
registry = Registry()
|
|
19
|
+
try:
|
|
20
|
+
if arguments.plugin:
|
|
21
|
+
entries = discover_providers()
|
|
22
|
+
for name in arguments.plugin:
|
|
23
|
+
matches = [entry for entry in entries if entry.name == name]
|
|
24
|
+
if len(matches) != 1:
|
|
25
|
+
parser.error(f"Provider {name!r} must be installed and unambiguous.")
|
|
26
|
+
load_provider(matches[0], registry)
|
|
27
|
+
catalog = CapabilityCatalog(Moves(registry=registry), arguments.moves)
|
|
28
|
+
server = MCPAdapter(catalog).server(arguments.name)
|
|
29
|
+
except (MoveError, ValueError) as error:
|
|
30
|
+
parser.error(str(error))
|
|
31
|
+
asyncio.run(serve_stdio(server))
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""A bounded local HTTP host; the SDK owns all MCP protocol handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
6
|
+
from math import isfinite
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
import uvicorn
|
|
10
|
+
from starlette.applications import Starlette
|
|
11
|
+
from starlette.requests import Request
|
|
12
|
+
from starlette.responses import JSONResponse
|
|
13
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
14
|
+
|
|
15
|
+
from hidden_moves.adapters import CapabilityCatalog
|
|
16
|
+
|
|
17
|
+
from .server import MCPAdapter
|
|
18
|
+
|
|
19
|
+
RequestAuthorizer = Callable[[Request], Awaitable[bool]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _RequestGuard:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
app: ASGIApp,
|
|
26
|
+
*,
|
|
27
|
+
authorize: RequestAuthorizer,
|
|
28
|
+
timeout: float,
|
|
29
|
+
concurrency: int,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.app = app
|
|
32
|
+
self.authorize = authorize
|
|
33
|
+
self.timeout = timeout
|
|
34
|
+
self.capacity = anyio.CapacityLimiter(concurrency)
|
|
35
|
+
|
|
36
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
37
|
+
if scope["type"] != "http":
|
|
38
|
+
await self.app(scope, receive, send)
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
self.capacity.acquire_nowait()
|
|
42
|
+
except anyio.WouldBlock:
|
|
43
|
+
await JSONResponse({"error": "request capacity exceeded"}, status_code=503)(
|
|
44
|
+
scope, receive, send
|
|
45
|
+
)
|
|
46
|
+
return
|
|
47
|
+
started = False
|
|
48
|
+
completed = False
|
|
49
|
+
|
|
50
|
+
async def tracked_send(message: Message) -> None:
|
|
51
|
+
nonlocal started, completed
|
|
52
|
+
if message["type"] == "http.response.start":
|
|
53
|
+
started = True
|
|
54
|
+
elif message["type"] == "http.response.body":
|
|
55
|
+
completed = not message.get("more_body", False)
|
|
56
|
+
await send(message)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
with anyio.fail_after(self.timeout):
|
|
60
|
+
try:
|
|
61
|
+
permitted = await self.authorize(Request(scope, receive))
|
|
62
|
+
except Exception:
|
|
63
|
+
# Upstream identity failures may contain credentials.
|
|
64
|
+
permitted = False
|
|
65
|
+
if permitted is not True:
|
|
66
|
+
await JSONResponse(
|
|
67
|
+
{"error": "request authorization required"}, status_code=401
|
|
68
|
+
)(scope, receive, tracked_send)
|
|
69
|
+
return
|
|
70
|
+
await self.app(scope, receive, tracked_send)
|
|
71
|
+
except TimeoutError:
|
|
72
|
+
if not started:
|
|
73
|
+
await JSONResponse(
|
|
74
|
+
{"error": "request deadline exceeded"}, status_code=504
|
|
75
|
+
)(scope, receive, send)
|
|
76
|
+
elif not completed:
|
|
77
|
+
await send(
|
|
78
|
+
{"type": "http.response.body", "body": b"", "more_body": False}
|
|
79
|
+
)
|
|
80
|
+
finally:
|
|
81
|
+
self.capacity.release()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def create_http_app(
|
|
85
|
+
catalog: CapabilityCatalog,
|
|
86
|
+
*,
|
|
87
|
+
authorize: RequestAuthorizer,
|
|
88
|
+
name: str = "hidden-moves",
|
|
89
|
+
version: str = "0.1.0",
|
|
90
|
+
tool_names: Mapping[str, str] | None = None,
|
|
91
|
+
call_timeout: float = 10,
|
|
92
|
+
request_timeout: float = 30,
|
|
93
|
+
max_concurrency: int = 16,
|
|
94
|
+
max_request_body_size: int = 1024 * 1024,
|
|
95
|
+
offload_sync: bool = True,
|
|
96
|
+
) -> Starlette:
|
|
97
|
+
"""Create a stateless, JSON-response app for explicitly authorized local use.
|
|
98
|
+
|
|
99
|
+
The async authorizer must return exactly True for each allowed request. Its
|
|
100
|
+
identity/credential policy and capability resource lifetimes belong to the
|
|
101
|
+
application. Construction opens no listener and resolves no credentials.
|
|
102
|
+
"""
|
|
103
|
+
if not callable(authorize):
|
|
104
|
+
raise ValueError("An explicit asynchronous request authorizer is required.")
|
|
105
|
+
if (
|
|
106
|
+
isinstance(request_timeout, bool)
|
|
107
|
+
or not isinstance(request_timeout, (int, float))
|
|
108
|
+
or not isfinite(request_timeout)
|
|
109
|
+
or not 0 < request_timeout <= 120
|
|
110
|
+
):
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"request_timeout must be finite and between 0 and 120 seconds."
|
|
113
|
+
)
|
|
114
|
+
if (
|
|
115
|
+
isinstance(max_concurrency, bool)
|
|
116
|
+
or not isinstance(max_concurrency, int)
|
|
117
|
+
or not 1 <= max_concurrency <= 64
|
|
118
|
+
):
|
|
119
|
+
raise ValueError("max_concurrency must be between 1 and 64.")
|
|
120
|
+
if (
|
|
121
|
+
isinstance(max_request_body_size, bool)
|
|
122
|
+
or not isinstance(max_request_body_size, int)
|
|
123
|
+
or not 1 <= max_request_body_size <= 4 * 1024 * 1024
|
|
124
|
+
):
|
|
125
|
+
raise ValueError("max_request_body_size must be between 1 byte and 4 MiB.")
|
|
126
|
+
adapter = MCPAdapter(
|
|
127
|
+
catalog,
|
|
128
|
+
tool_names=tool_names,
|
|
129
|
+
offload_sync=offload_sync,
|
|
130
|
+
call_timeout=call_timeout,
|
|
131
|
+
)
|
|
132
|
+
if call_timeout is None or call_timeout > request_timeout:
|
|
133
|
+
raise ValueError("call_timeout must not exceed request_timeout.")
|
|
134
|
+
app = adapter.server(name, version=version).streamable_http_app(
|
|
135
|
+
host="127.0.0.1",
|
|
136
|
+
stateless_http=True,
|
|
137
|
+
json_response=True,
|
|
138
|
+
max_request_body_size=max_request_body_size,
|
|
139
|
+
session_idle_timeout=request_timeout,
|
|
140
|
+
max_sessions=max_concurrency,
|
|
141
|
+
)
|
|
142
|
+
app.add_middleware(
|
|
143
|
+
_RequestGuard,
|
|
144
|
+
authorize=authorize,
|
|
145
|
+
timeout=request_timeout,
|
|
146
|
+
concurrency=max_concurrency,
|
|
147
|
+
)
|
|
148
|
+
return app
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def serve_http(app: Starlette, *, port: int = 8000) -> None:
|
|
152
|
+
"""Serve the local app on IPv4 loopback until shutdown, including its lifespan."""
|
|
153
|
+
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
|
|
154
|
+
raise ValueError("port must be between 1 and 65535.")
|
|
155
|
+
config = uvicorn.Config(
|
|
156
|
+
app,
|
|
157
|
+
host="127.0.0.1",
|
|
158
|
+
port=port,
|
|
159
|
+
access_log=False,
|
|
160
|
+
log_level="warning",
|
|
161
|
+
proxy_headers=False,
|
|
162
|
+
lifespan="on",
|
|
163
|
+
timeout_keep_alive=5,
|
|
164
|
+
timeout_graceful_shutdown=5,
|
|
165
|
+
)
|
|
166
|
+
await uvicorn.Server(config).serve()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""MCP tools consume the neutral catalog without changing its definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from contextlib import nullcontext
|
|
10
|
+
from math import isfinite
|
|
11
|
+
|
|
12
|
+
import anyio
|
|
13
|
+
from mcp import MCPError
|
|
14
|
+
from mcp.server import Server, ServerRequestContext
|
|
15
|
+
from mcp.server.stdio import stdio_server
|
|
16
|
+
from mcp.types import (
|
|
17
|
+
INVALID_PARAMS,
|
|
18
|
+
CallToolRequestParams,
|
|
19
|
+
CallToolResult,
|
|
20
|
+
ListToolsResult,
|
|
21
|
+
PaginatedRequestParams,
|
|
22
|
+
TextContent,
|
|
23
|
+
Tool,
|
|
24
|
+
ToolAnnotations,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from hidden_moves.adapters import (
|
|
28
|
+
CapabilityArgumentError,
|
|
29
|
+
CapabilityCatalog,
|
|
30
|
+
CapabilityResultError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_TOOL_NAME = re.compile(r"[A-Za-z0-9_.-]{1,128}\Z")
|
|
34
|
+
_HINT_NAMES = {
|
|
35
|
+
"read_only": "read_only_hint",
|
|
36
|
+
"destructive": "destructive_hint",
|
|
37
|
+
"idempotent": "idempotent_hint",
|
|
38
|
+
"external": "open_world_hint",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MCPAdapter:
|
|
43
|
+
"""Export and dispatch exactly the catalog's selected capabilities."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
catalog: CapabilityCatalog,
|
|
48
|
+
*,
|
|
49
|
+
tool_names: Mapping[str, str] | None = None,
|
|
50
|
+
offload_sync: bool = False,
|
|
51
|
+
call_timeout: float | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
if not isinstance(offload_sync, bool):
|
|
54
|
+
raise ValueError("offload_sync must be a boolean.")
|
|
55
|
+
if call_timeout is not None and (
|
|
56
|
+
isinstance(call_timeout, bool) or not isinstance(call_timeout, (int, float))
|
|
57
|
+
or not isfinite(call_timeout) or not 0 < call_timeout <= 120
|
|
58
|
+
):
|
|
59
|
+
raise ValueError("call_timeout must be finite and between 0 and 120 seconds.")
|
|
60
|
+
self._offload_sync = offload_sync
|
|
61
|
+
self._call_timeout = call_timeout
|
|
62
|
+
self.catalog = catalog
|
|
63
|
+
aliases = dict(tool_names or {})
|
|
64
|
+
selected = {definition.name for definition in catalog.definitions()}
|
|
65
|
+
if set(aliases) - selected:
|
|
66
|
+
raise ValueError("Tool aliases must reference selected capabilities.")
|
|
67
|
+
self._names = {}
|
|
68
|
+
for definition in catalog.definitions():
|
|
69
|
+
name = aliases.get(definition.name, definition.name)
|
|
70
|
+
if not isinstance(name, str) or not _TOOL_NAME.fullmatch(name):
|
|
71
|
+
raise ValueError("MCP tool names require 1-128 ASCII letters, digits, underscores, hyphens, or dots; supply an alias.")
|
|
72
|
+
if name in self._names:
|
|
73
|
+
raise ValueError(f"MCP tool name collision: {name!r}.")
|
|
74
|
+
self._names[name] = definition.name
|
|
75
|
+
|
|
76
|
+
def tools(self) -> list[Tool]:
|
|
77
|
+
tools = []
|
|
78
|
+
for name, qualified_name in self._names.items():
|
|
79
|
+
definition = self.catalog.describe(qualified_name)
|
|
80
|
+
data = definition.to_dict()
|
|
81
|
+
hints = {
|
|
82
|
+
_HINT_NAMES[key]: value for key, value in definition.annotations.to_dict().items()
|
|
83
|
+
if value is not None
|
|
84
|
+
}
|
|
85
|
+
tools.append(Tool(
|
|
86
|
+
name=name,
|
|
87
|
+
description=definition.description,
|
|
88
|
+
input_schema=data["input_schema"],
|
|
89
|
+
output_schema={
|
|
90
|
+
"type": "object",
|
|
91
|
+
"properties": {"result": data["output_schema"] or {}},
|
|
92
|
+
"required": ["result"],
|
|
93
|
+
"additionalProperties": False,
|
|
94
|
+
},
|
|
95
|
+
annotations=ToolAnnotations(**hints) if hints else None,
|
|
96
|
+
))
|
|
97
|
+
return tools
|
|
98
|
+
|
|
99
|
+
async def _list_tools(
|
|
100
|
+
self,
|
|
101
|
+
_context: ServerRequestContext,
|
|
102
|
+
params: PaginatedRequestParams | None,
|
|
103
|
+
) -> ListToolsResult:
|
|
104
|
+
if params is not None and params.cursor:
|
|
105
|
+
raise MCPError(code=INVALID_PARAMS, message="This catalog has no pagination cursors.")
|
|
106
|
+
return ListToolsResult(tools=self.tools())
|
|
107
|
+
|
|
108
|
+
async def _call_tool(
|
|
109
|
+
self,
|
|
110
|
+
_context: ServerRequestContext,
|
|
111
|
+
params: CallToolRequestParams,
|
|
112
|
+
) -> CallToolResult:
|
|
113
|
+
try:
|
|
114
|
+
qualified_name = self._names[params.name]
|
|
115
|
+
except KeyError as error:
|
|
116
|
+
raise MCPError(code=INVALID_PARAMS, message="Unknown or unexposed tool.") from error
|
|
117
|
+
try:
|
|
118
|
+
budget = anyio.fail_after(self._call_timeout) if self._call_timeout is not None else nullcontext()
|
|
119
|
+
with budget:
|
|
120
|
+
arguments = params.arguments if params.arguments is not None else {}
|
|
121
|
+
if self._offload_sync and not self.catalog.describe(qualified_name).is_async:
|
|
122
|
+
result = await anyio.to_thread.run_sync(
|
|
123
|
+
lambda: self.catalog.invoke(qualified_name, arguments), abandon_on_cancel=True,
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
result = self.catalog.invoke(qualified_name, arguments)
|
|
127
|
+
if inspect.isawaitable(result):
|
|
128
|
+
result = await result
|
|
129
|
+
value = self.catalog.serialize_result(qualified_name, result)
|
|
130
|
+
except (CapabilityArgumentError, CapabilityResultError) as error:
|
|
131
|
+
return CallToolResult(is_error=True, content=[TextContent(type="text", text=str(error))])
|
|
132
|
+
except Exception as error:
|
|
133
|
+
# Capability errors may contain private client state; report the failure type.
|
|
134
|
+
return CallToolResult(is_error=True, content=[TextContent(
|
|
135
|
+
type="text", text=f"Capability {params.name!r} failed ({type(error).__name__}).",
|
|
136
|
+
)])
|
|
137
|
+
structured = {"result": value}
|
|
138
|
+
return CallToolResult(
|
|
139
|
+
content=[TextContent(type="text", text=json.dumps(structured, ensure_ascii=False, allow_nan=False))],
|
|
140
|
+
structured_content=structured,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def server(self, name: str = "hidden-moves", *, version: str = "0.1.0") -> Server:
|
|
144
|
+
return Server(name, version=version, on_list_tools=self._list_tools, on_call_tool=self._call_tool)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def serve_stdio(server: Server) -> None:
|
|
148
|
+
"""Serve until the host closes stdin; the SDK owns the protocol connection."""
|
|
149
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
150
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|