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.
@@ -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,6 @@
1
+ """An optional MCP consumer of the neutral capability catalog."""
2
+
3
+ from .http import create_http_app, serve_http
4
+ from .server import MCPAdapter, serve_stdio
5
+
6
+ __all__ = ["MCPAdapter", "create_http_app", "serve_http", "serve_stdio"]
@@ -0,0 +1,6 @@
1
+ """Support python -m hidden_moves_mcp without starting a server on import."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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())