appmcp 0.1.0__py3-none-any.whl
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.
- appmcp/__init__.py +18 -0
- appmcp/adapters/__init__.py +5 -0
- appmcp/adapters/base.py +13 -0
- appmcp/adapters/fastmcp.py +41 -0
- appmcp/adapters/official_sdk.py +48 -0
- appmcp/application.py +264 -0
- appmcp/audit.py +45 -0
- appmcp/cli.py +89 -0
- appmcp/config.py +16 -0
- appmcp/context.py +20 -0
- appmcp/decorators.py +85 -0
- appmcp/definitions.py +39 -0
- appmcp/dependencies.py +40 -0
- appmcp/exceptions.py +30 -0
- appmcp/integrations/__init__.py +3 -0
- appmcp/integrations/asgi.py +147 -0
- appmcp/integrations/django.py +5 -0
- appmcp/integrations/fastapi.py +5 -0
- appmcp/integrations/flask.py +8 -0
- appmcp/integrations/starlette.py +5 -0
- appmcp/plugins.py +17 -0
- appmcp/py.typed +0 -0
- appmcp/registry.py +66 -0
- appmcp/security/__init__.py +14 -0
- appmcp/security/auth.py +56 -0
- appmcp/security/policies.py +30 -0
- appmcp/security/rate_limits.py +60 -0
- appmcp/security/scopes.py +2 -0
- appmcp/sessions.py +40 -0
- appmcp/settings.py +11 -0
- appmcp/telemetry.py +16 -0
- appmcp/testing/__init__.py +3 -0
- appmcp/testing/client.py +46 -0
- appmcp-0.1.0.dist-info/METADATA +671 -0
- appmcp-0.1.0.dist-info/RECORD +38 -0
- appmcp-0.1.0.dist-info/WHEEL +4 -0
- appmcp-0.1.0.dist-info/entry_points.txt +2 -0
- appmcp-0.1.0.dist-info/licenses/LICENSE +12 -0
appmcp/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .application import AppMCP
|
|
2
|
+
from .context import MCPContext
|
|
3
|
+
from .decorators import prompt, resource, tool
|
|
4
|
+
from .dependencies import Depends
|
|
5
|
+
from .security import BearerAuth, Identity, OAuthAuth
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AppMCP",
|
|
10
|
+
"MCPContext",
|
|
11
|
+
"tool",
|
|
12
|
+
"resource",
|
|
13
|
+
"prompt",
|
|
14
|
+
"Depends",
|
|
15
|
+
"BearerAuth",
|
|
16
|
+
"OAuthAuth",
|
|
17
|
+
"Identity",
|
|
18
|
+
]
|
appmcp/adapters/base.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
from ..definitions import Definition
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MCPBackend(Protocol):
|
|
9
|
+
def add_tool(self, definition: Definition) -> None: ...
|
|
10
|
+
def add_resource(self, definition: Definition) -> None: ...
|
|
11
|
+
def add_prompt(self, definition: Definition) -> None: ...
|
|
12
|
+
def create_asgi_app(self, path: str = "/", **options: Any) -> Any: ...
|
|
13
|
+
async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any: ...
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..definitions import Definition
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FastMCPBackend:
|
|
9
|
+
"""FastMCP 3 adapter. FastMCP is imported only when this backend is created."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, name: str, **options: Any) -> None:
|
|
12
|
+
try:
|
|
13
|
+
from fastmcp import FastMCP
|
|
14
|
+
except ImportError as exc:
|
|
15
|
+
raise ImportError("FastMCP is required: pip install appmcp") from exc
|
|
16
|
+
self.server = FastMCP(name=name, **options)
|
|
17
|
+
|
|
18
|
+
def add_tool(self, definition: Definition) -> None:
|
|
19
|
+
kwargs = {"name": definition.name, "description": definition.description}
|
|
20
|
+
if definition.read_only:
|
|
21
|
+
kwargs["annotations"] = {"readOnlyHint": True}
|
|
22
|
+
self.server.tool(**kwargs)(definition.callable)
|
|
23
|
+
|
|
24
|
+
def add_resource(self, definition: Definition) -> None:
|
|
25
|
+
self.server.resource(
|
|
26
|
+
definition.uri, name=definition.name, description=definition.description
|
|
27
|
+
)(definition.callable)
|
|
28
|
+
|
|
29
|
+
def add_prompt(self, definition: Definition) -> None:
|
|
30
|
+
self.server.prompt(name=definition.name, description=definition.description)(
|
|
31
|
+
definition.callable
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def create_asgi_app(self, path: str = "/", **options: Any) -> Any:
|
|
35
|
+
return self.server.http_app(path=path, **options)
|
|
36
|
+
|
|
37
|
+
async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
|
|
38
|
+
return await self.server.call_tool(name, arguments)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
FastMCPAdapter = FastMCPBackend
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..definitions import Definition
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OfficialSDKBackend:
|
|
9
|
+
"""Adapter for the official SDK's FastMCP compatibility surface.
|
|
10
|
+
|
|
11
|
+
Kept separate from the default FastMCP 3 engine so applications can opt in
|
|
12
|
+
and compatibility changes remain contained when official SDK v2 stabilizes.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, name: str, **options: Any):
|
|
16
|
+
try:
|
|
17
|
+
from mcp.server.fastmcp import FastMCP
|
|
18
|
+
except ImportError as exc:
|
|
19
|
+
raise ImportError("OfficialSDKBackend requires the 'mcp' Python package") from exc
|
|
20
|
+
self.server = FastMCP(name, **options)
|
|
21
|
+
|
|
22
|
+
def add_tool(self, definition: Definition) -> None:
|
|
23
|
+
self.server.tool(name=definition.name, description=definition.description)(
|
|
24
|
+
definition.callable
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def add_resource(self, definition: Definition) -> None:
|
|
28
|
+
self.server.resource(
|
|
29
|
+
definition.uri, name=definition.name, description=definition.description
|
|
30
|
+
)(definition.callable)
|
|
31
|
+
|
|
32
|
+
def add_prompt(self, definition: Definition) -> None:
|
|
33
|
+
self.server.prompt(name=definition.name, description=definition.description)(
|
|
34
|
+
definition.callable
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def create_asgi_app(self, path: str = "/", **options: Any) -> Any:
|
|
38
|
+
if hasattr(self.server, "streamable_http_app"):
|
|
39
|
+
return self.server.streamable_http_app()
|
|
40
|
+
if hasattr(self.server, "http_app"):
|
|
41
|
+
return self.server.http_app(path=path)
|
|
42
|
+
raise RuntimeError("Installed official MCP SDK does not provide an ASGI HTTP transport")
|
|
43
|
+
|
|
44
|
+
async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
|
|
45
|
+
manager = getattr(self.server, "_tool_manager", None)
|
|
46
|
+
if manager is None:
|
|
47
|
+
raise RuntimeError("Installed official MCP SDK has no in-process tool manager")
|
|
48
|
+
return await manager.call_tool(name, arguments)
|
appmcp/application.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any, get_type_hints
|
|
9
|
+
|
|
10
|
+
from .audit import AuditLogger
|
|
11
|
+
from .context import MCPContext
|
|
12
|
+
from .decorators import prompt as prompt_decorator
|
|
13
|
+
from .decorators import resource as resource_decorator
|
|
14
|
+
from .decorators import tool as tool_decorator
|
|
15
|
+
from .definitions import Definition
|
|
16
|
+
from .dependencies import DependencyContainer, Depends
|
|
17
|
+
from .exceptions import AppMCPError
|
|
18
|
+
from .integrations.asgi import mount_asgi, request_scope
|
|
19
|
+
from .registry import Registry
|
|
20
|
+
from .security import InMemoryRateLimiter, SecurityPolicy
|
|
21
|
+
from .sessions import MemorySessionStore
|
|
22
|
+
from .settings import Settings
|
|
23
|
+
from .telemetry import Telemetry
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AppMCP:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
app: Any = None,
|
|
30
|
+
*,
|
|
31
|
+
name: str = "AppMCP",
|
|
32
|
+
path: str = "/mcp",
|
|
33
|
+
auth: Any = None,
|
|
34
|
+
default_deny: bool = False,
|
|
35
|
+
expose_errors: bool = False,
|
|
36
|
+
backend: Any = None,
|
|
37
|
+
session_store: Any = None,
|
|
38
|
+
audit: AuditLogger | None = None,
|
|
39
|
+
middleware: list[Callable] | None = None,
|
|
40
|
+
telemetry: Telemetry | None = None,
|
|
41
|
+
auto_mount: bool = True,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.name = name
|
|
44
|
+
self.settings = Settings(path=path, default_deny=default_deny, expose_errors=expose_errors)
|
|
45
|
+
self.app = app
|
|
46
|
+
self.auth = auth
|
|
47
|
+
self.registry = Registry()
|
|
48
|
+
self.dependencies = DependencyContainer()
|
|
49
|
+
self.policy = SecurityPolicy(default_deny=default_deny)
|
|
50
|
+
self.rate_limiter = InMemoryRateLimiter()
|
|
51
|
+
self.session_store = session_store or MemorySessionStore()
|
|
52
|
+
self.audit = audit or AuditLogger()
|
|
53
|
+
self.middleware = list(middleware or [])
|
|
54
|
+
self.telemetry = telemetry or Telemetry()
|
|
55
|
+
self.backend = backend
|
|
56
|
+
self._mounted_app = None
|
|
57
|
+
if app is not None and auto_mount:
|
|
58
|
+
self.mount(app, path=path)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_app(cls, app: Any, **kwargs: Any) -> AppMCP:
|
|
62
|
+
return cls(app, **kwargs)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def depends(key: str) -> Depends:
|
|
66
|
+
return Depends(key)
|
|
67
|
+
|
|
68
|
+
def provide(self, key: str, provider: Any) -> AppMCP:
|
|
69
|
+
self.dependencies.provide(key, provider)
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def _register(self, target: Any, namespace: str | None = None) -> Definition:
|
|
73
|
+
definition = self.registry.register(target, namespace)
|
|
74
|
+
definition.callable = self._transport_callable(definition)
|
|
75
|
+
if self.backend is not None:
|
|
76
|
+
getattr(self.backend, f"add_{definition.kind}")(definition)
|
|
77
|
+
return definition
|
|
78
|
+
|
|
79
|
+
def tool(self, *args: Any, **kwargs: Any):
|
|
80
|
+
def register(fn):
|
|
81
|
+
tool_decorator(*args, **kwargs)(fn)
|
|
82
|
+
self._register(fn)
|
|
83
|
+
return fn
|
|
84
|
+
|
|
85
|
+
return register
|
|
86
|
+
|
|
87
|
+
def resource(self, uri: str, **kwargs: Any):
|
|
88
|
+
def register(fn):
|
|
89
|
+
resource_decorator(uri, **kwargs)(fn)
|
|
90
|
+
self._register(fn)
|
|
91
|
+
return fn
|
|
92
|
+
|
|
93
|
+
return register
|
|
94
|
+
|
|
95
|
+
def prompt(self, *args: Any, **kwargs: Any):
|
|
96
|
+
def register(fn):
|
|
97
|
+
prompt_decorator(*args, **kwargs)(fn)
|
|
98
|
+
self._register(fn)
|
|
99
|
+
return fn
|
|
100
|
+
|
|
101
|
+
return register
|
|
102
|
+
|
|
103
|
+
def include(self, instance: Any, namespace: str | None = None) -> AppMCP:
|
|
104
|
+
for definition in self.registry.include(instance, namespace):
|
|
105
|
+
definition.callable = self._transport_callable(definition)
|
|
106
|
+
if self.backend is not None:
|
|
107
|
+
getattr(self.backend, f"add_{definition.kind}")(definition)
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
include_instance = include
|
|
111
|
+
|
|
112
|
+
def use(self, middleware: Callable) -> AppMCP:
|
|
113
|
+
self.middleware.append(middleware)
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def _ensure_backend(self):
|
|
117
|
+
if self.backend is None:
|
|
118
|
+
from .adapters.fastmcp import FastMCPBackend
|
|
119
|
+
|
|
120
|
+
self.backend = FastMCPBackend(self.name)
|
|
121
|
+
for definition in self.registry.all():
|
|
122
|
+
getattr(self.backend, f"add_{definition.kind}")(definition)
|
|
123
|
+
return self.backend
|
|
124
|
+
|
|
125
|
+
def mount(self, app: Any = None, *, path: str | None = None) -> Any:
|
|
126
|
+
app = app or self.app
|
|
127
|
+
if app is None:
|
|
128
|
+
raise ValueError("mount() requires an ASGI application")
|
|
129
|
+
self.app = app
|
|
130
|
+
path = path or self.settings.path
|
|
131
|
+
mcp_app = self._ensure_backend().create_asgi_app(
|
|
132
|
+
path="/",
|
|
133
|
+
json_response=self.settings.json_response,
|
|
134
|
+
stateless_http=self.settings.stateless_http,
|
|
135
|
+
)
|
|
136
|
+
self._mounted_app = mount_asgi(app, mcp_app, path)
|
|
137
|
+
return self._mounted_app
|
|
138
|
+
|
|
139
|
+
def asgi_app(self, *, path: str = "/") -> Any:
|
|
140
|
+
return self._ensure_backend().create_asgi_app(
|
|
141
|
+
path=path,
|
|
142
|
+
json_response=self.settings.json_response,
|
|
143
|
+
stateless_http=self.settings.stateless_http,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
async def _context(
|
|
147
|
+
self, *, headers=None, user=None, scopes=(), confirmed=False, session=None, metadata=None
|
|
148
|
+
) -> MCPContext:
|
|
149
|
+
scope = request_scope.get() or {}
|
|
150
|
+
raw_headers = scope.get("headers", ())
|
|
151
|
+
scope_headers = {k.decode("latin1"): v.decode("latin1") for k, v in raw_headers}
|
|
152
|
+
headers = {**scope_headers, **(headers or {})}
|
|
153
|
+
if self.auth is not None and user is None:
|
|
154
|
+
identity = await self.auth.authenticate(headers)
|
|
155
|
+
if identity:
|
|
156
|
+
user, scopes = identity.user, identity.scopes
|
|
157
|
+
request_id = next(
|
|
158
|
+
(v for k, v in headers.items() if k.lower() == "x-request-id"), str(uuid.uuid4())
|
|
159
|
+
)
|
|
160
|
+
return MCPContext(
|
|
161
|
+
user=user,
|
|
162
|
+
headers=headers,
|
|
163
|
+
request_id=request_id,
|
|
164
|
+
session=session,
|
|
165
|
+
app=self.app,
|
|
166
|
+
services=self.dependencies,
|
|
167
|
+
logger=logging.getLogger(f"appmcp.{self.name}"),
|
|
168
|
+
scopes=frozenset(scopes),
|
|
169
|
+
confirmed=confirmed,
|
|
170
|
+
metadata=metadata or {},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
async def call_tool(self, name: str, arguments: dict[str, Any], **context: Any) -> Any:
|
|
174
|
+
return await self.call("tool", name, arguments, **context)
|
|
175
|
+
|
|
176
|
+
async def call(self, kind: str, name: str, arguments: dict[str, Any], **context: Any) -> Any:
|
|
177
|
+
definition = self.registry.get(kind, name)
|
|
178
|
+
ctx = await self._context(**context)
|
|
179
|
+
await self.policy.check(definition, ctx)
|
|
180
|
+
await self.rate_limiter.check(definition, ctx)
|
|
181
|
+
kwargs = dict(arguments)
|
|
182
|
+
signature = inspect.signature(
|
|
183
|
+
definition.metadata.get("original_callable", definition.callable)
|
|
184
|
+
)
|
|
185
|
+
try:
|
|
186
|
+
hints = get_type_hints(
|
|
187
|
+
definition.metadata.get("original_callable", definition.callable)
|
|
188
|
+
)
|
|
189
|
+
except (NameError, TypeError):
|
|
190
|
+
hints = {}
|
|
191
|
+
for parameter in signature.parameters.values():
|
|
192
|
+
if parameter.name in kwargs:
|
|
193
|
+
continue
|
|
194
|
+
annotation = hints.get(parameter.name, parameter.annotation)
|
|
195
|
+
if (
|
|
196
|
+
annotation is MCPContext
|
|
197
|
+
or parameter.name == "ctx"
|
|
198
|
+
and parameter.default is inspect.Parameter.empty
|
|
199
|
+
):
|
|
200
|
+
kwargs[parameter.name] = ctx
|
|
201
|
+
elif isinstance(parameter.default, Depends):
|
|
202
|
+
kwargs[parameter.name] = await self.dependencies.resolve(parameter.default.key, ctx)
|
|
203
|
+
|
|
204
|
+
async def invoke():
|
|
205
|
+
value = definition.metadata["original_callable"](**kwargs)
|
|
206
|
+
return await value if inspect.isawaitable(value) else value
|
|
207
|
+
|
|
208
|
+
handler = invoke
|
|
209
|
+
for middleware in reversed(self.middleware):
|
|
210
|
+
next_handler = handler
|
|
211
|
+
|
|
212
|
+
async def handler(middleware=middleware, next_handler=next_handler):
|
|
213
|
+
value = middleware(definition, ctx, kwargs, next_handler)
|
|
214
|
+
return await value if inspect.isawaitable(value) else value
|
|
215
|
+
|
|
216
|
+
error = None
|
|
217
|
+
try:
|
|
218
|
+
with self.telemetry.span(f"appmcp.{kind}.{name}"):
|
|
219
|
+
return await handler()
|
|
220
|
+
except Exception as exc:
|
|
221
|
+
error = exc
|
|
222
|
+
if self.settings.expose_errors or isinstance(exc, AppMCPError):
|
|
223
|
+
raise
|
|
224
|
+
raise AppMCPError("Operation failed") from exc
|
|
225
|
+
finally:
|
|
226
|
+
await self.audit.record(name, ctx, arguments, error)
|
|
227
|
+
|
|
228
|
+
def _transport_callable(self, definition: Definition):
|
|
229
|
+
original = definition.callable
|
|
230
|
+
definition.metadata["original_callable"] = original
|
|
231
|
+
signature = inspect.signature(original)
|
|
232
|
+
try:
|
|
233
|
+
hints = get_type_hints(original)
|
|
234
|
+
except (NameError, TypeError):
|
|
235
|
+
hints = {}
|
|
236
|
+
public = [
|
|
237
|
+
p
|
|
238
|
+
for p in signature.parameters.values()
|
|
239
|
+
if p.annotation is not MCPContext
|
|
240
|
+
and not isinstance(p.default, Depends)
|
|
241
|
+
and p.name != "ctx"
|
|
242
|
+
]
|
|
243
|
+
public = [p for p in public if hints.get(p.name, p.annotation) is not MCPContext]
|
|
244
|
+
|
|
245
|
+
@functools.wraps(original)
|
|
246
|
+
async def wrapper(**kwargs):
|
|
247
|
+
return await self.call(definition.kind, definition.name, kwargs)
|
|
248
|
+
|
|
249
|
+
wrapper.__signature__ = signature.replace(parameters=public) # type: ignore[attr-defined]
|
|
250
|
+
return wrapper
|
|
251
|
+
|
|
252
|
+
def test_client(self, **kwargs: Any):
|
|
253
|
+
from .testing import TestClient
|
|
254
|
+
|
|
255
|
+
return TestClient(self, **kwargs)
|
|
256
|
+
|
|
257
|
+
def inspect(self) -> dict[str, Any]:
|
|
258
|
+
return {
|
|
259
|
+
"name": self.name,
|
|
260
|
+
"path": self.settings.path,
|
|
261
|
+
"tools": [d.name for d in self.registry.all("tool")],
|
|
262
|
+
"resources": [{"name": d.name, "uri": d.uri} for d in self.registry.all("resource")],
|
|
263
|
+
"prompts": [d.name for d in self.registry.all("prompt")],
|
|
264
|
+
}
|
appmcp/audit.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class AuditEvent:
|
|
13
|
+
operation: str
|
|
14
|
+
user: Any
|
|
15
|
+
arguments: dict[str, Any]
|
|
16
|
+
success: bool
|
|
17
|
+
error: str | None
|
|
18
|
+
timestamp: datetime
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuditLogger:
|
|
22
|
+
def __init__(self, sink: Callable[[AuditEvent], Any] | None = None) -> None:
|
|
23
|
+
self.sink = sink
|
|
24
|
+
|
|
25
|
+
async def record(
|
|
26
|
+
self,
|
|
27
|
+
operation: str,
|
|
28
|
+
context: Any,
|
|
29
|
+
arguments: dict[str, Any],
|
|
30
|
+
error: Exception | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
event = AuditEvent(
|
|
33
|
+
operation,
|
|
34
|
+
context.user,
|
|
35
|
+
arguments,
|
|
36
|
+
error is None,
|
|
37
|
+
str(error) if error else None,
|
|
38
|
+
datetime.now(timezone.utc),
|
|
39
|
+
)
|
|
40
|
+
if self.sink:
|
|
41
|
+
result = self.sink(event)
|
|
42
|
+
if inspect.isawaitable(result):
|
|
43
|
+
await result
|
|
44
|
+
else:
|
|
45
|
+
logging.getLogger("appmcp.audit").info("%s", event)
|
appmcp/cli.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .application import AppMCP
|
|
9
|
+
from .config import config_json
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_object(reference: str) -> Any:
|
|
13
|
+
try:
|
|
14
|
+
module_name, object_path = reference.split(":", 1)
|
|
15
|
+
except ValueError as exc:
|
|
16
|
+
raise ValueError("Application reference must look like package.module:object") from exc
|
|
17
|
+
value = importlib.import_module(module_name)
|
|
18
|
+
for part in object_path.split("."):
|
|
19
|
+
value = getattr(value, part)
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def find_appmcp(value: Any) -> AppMCP:
|
|
24
|
+
if isinstance(value, AppMCP):
|
|
25
|
+
return value
|
|
26
|
+
for candidate in vars(importlib.import_module(value.__module__)).values():
|
|
27
|
+
if isinstance(candidate, AppMCP) and (
|
|
28
|
+
candidate.app is value or value is candidate._mounted_app
|
|
29
|
+
):
|
|
30
|
+
return candidate
|
|
31
|
+
raise ValueError("No AppMCP instance is associated with that application")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def inspect_command(reference: str) -> int:
|
|
35
|
+
info = find_appmcp(load_object(reference)).inspect()
|
|
36
|
+
print(f"Application: {info['name']}\nMCP path: {info['path']}\n")
|
|
37
|
+
for heading, key in (("Tools", "tools"), ("Resources", "resources"), ("Prompts", "prompts")):
|
|
38
|
+
print(f"{heading}:")
|
|
39
|
+
for item in info[key]:
|
|
40
|
+
print(f" {item['uri'] if isinstance(item, dict) else item}")
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def dev_command(reference: str, host: str, port: int) -> int:
|
|
45
|
+
try:
|
|
46
|
+
import uvicorn
|
|
47
|
+
except ImportError as exc:
|
|
48
|
+
raise SystemExit("Development server requires: pip install appmcp[fastapi]") from exc
|
|
49
|
+
uvicorn.run(reference, host=host, port=port, factory=False)
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_command(reference: str) -> int:
|
|
54
|
+
mcp = find_appmcp(load_object(reference))
|
|
55
|
+
info = mcp.inspect()
|
|
56
|
+
print(json.dumps(info, indent=2))
|
|
57
|
+
print("Registration validation passed")
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> int:
|
|
62
|
+
parser = argparse.ArgumentParser(prog="appmcp")
|
|
63
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
64
|
+
inspect_parser = sub.add_parser("inspect", help="List exposed capabilities")
|
|
65
|
+
inspect_parser.add_argument("application")
|
|
66
|
+
dev = sub.add_parser("dev", help="Run an ASGI development server")
|
|
67
|
+
dev.add_argument("application")
|
|
68
|
+
dev.add_argument("--host", default="127.0.0.1")
|
|
69
|
+
dev.add_argument("--port", type=int, default=8000)
|
|
70
|
+
test = sub.add_parser("test", help="Validate capability registration")
|
|
71
|
+
test.add_argument("application")
|
|
72
|
+
config = sub.add_parser("config", help="Generate MCP client configuration")
|
|
73
|
+
config.add_argument("client", choices=["claude", "cursor", "vscode"])
|
|
74
|
+
config.add_argument("application")
|
|
75
|
+
config.add_argument("--url", default="http://127.0.0.1:8000/mcp")
|
|
76
|
+
args = parser.parse_args(argv)
|
|
77
|
+
if args.command == "inspect":
|
|
78
|
+
return inspect_command(args.application)
|
|
79
|
+
if args.command == "dev":
|
|
80
|
+
return dev_command(args.application, args.host, args.port)
|
|
81
|
+
if args.command == "test":
|
|
82
|
+
return test_command(args.application)
|
|
83
|
+
mcp = find_appmcp(load_object(args.application))
|
|
84
|
+
print(config_json(args.client, url=args.url, name=mcp.name))
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
raise SystemExit(main())
|
appmcp/config.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def generate_config(client: str, *, url: str, name: str = "appmcp") -> dict[str, Any]:
|
|
8
|
+
client = client.lower()
|
|
9
|
+
server = {"url": url, "transport": "streamable-http"}
|
|
10
|
+
if client in {"claude", "cursor", "vscode"}:
|
|
11
|
+
return {"mcpServers": {name: server}}
|
|
12
|
+
raise ValueError(f"Unsupported client {client!r}; choose claude, cursor, or vscode")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def config_json(client: str, *, url: str, name: str = "appmcp") -> str:
|
|
16
|
+
return json.dumps(generate_config(client, url=url, name=name), indent=2)
|
appmcp/context.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class MCPContext:
|
|
11
|
+
user: Any = None
|
|
12
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
13
|
+
request_id: str = ""
|
|
14
|
+
session: Any = None
|
|
15
|
+
app: Any = None
|
|
16
|
+
services: Any = None
|
|
17
|
+
logger: logging.Logger = field(default_factory=lambda: logging.getLogger("appmcp"))
|
|
18
|
+
scopes: frozenset[str] = field(default_factory=frozenset)
|
|
19
|
+
confirmed: bool = False
|
|
20
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
appmcp/decorators.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, TypeVar
|
|
5
|
+
|
|
6
|
+
from .definitions import Exposure
|
|
7
|
+
|
|
8
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
9
|
+
MARKER = "__appmcp_exposure__"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _decorate(kind: str, **options: Any):
|
|
13
|
+
exposure = Exposure(kind=kind, **options)
|
|
14
|
+
|
|
15
|
+
def wrapper(fn: F) -> F:
|
|
16
|
+
setattr(fn, MARKER, exposure)
|
|
17
|
+
return fn
|
|
18
|
+
|
|
19
|
+
return wrapper
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def tool(
|
|
23
|
+
name: str | None = None,
|
|
24
|
+
*,
|
|
25
|
+
description: str | None = None,
|
|
26
|
+
scopes: list[str] | tuple[str, ...] = (),
|
|
27
|
+
rate_limit: str | None = None,
|
|
28
|
+
read_only: bool = False,
|
|
29
|
+
confirmation_required: bool = False,
|
|
30
|
+
enabled: bool | Callable[[Any], bool] = True,
|
|
31
|
+
**metadata: Any,
|
|
32
|
+
):
|
|
33
|
+
return _decorate(
|
|
34
|
+
"tool",
|
|
35
|
+
name=name,
|
|
36
|
+
description=description,
|
|
37
|
+
scopes=tuple(scopes),
|
|
38
|
+
rate_limit=rate_limit,
|
|
39
|
+
read_only=read_only,
|
|
40
|
+
confirmation_required=confirmation_required,
|
|
41
|
+
enabled=enabled,
|
|
42
|
+
metadata=metadata,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resource(
|
|
47
|
+
uri: str,
|
|
48
|
+
*,
|
|
49
|
+
name: str | None = None,
|
|
50
|
+
description: str | None = None,
|
|
51
|
+
scopes: list[str] | tuple[str, ...] = (),
|
|
52
|
+
enabled: bool | Callable[[Any], bool] = True,
|
|
53
|
+
**metadata: Any,
|
|
54
|
+
):
|
|
55
|
+
return _decorate(
|
|
56
|
+
"resource",
|
|
57
|
+
name=name,
|
|
58
|
+
uri=uri,
|
|
59
|
+
description=description,
|
|
60
|
+
scopes=tuple(scopes),
|
|
61
|
+
enabled=enabled,
|
|
62
|
+
metadata=metadata,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def prompt(
|
|
67
|
+
name: str | None = None,
|
|
68
|
+
*,
|
|
69
|
+
description: str | None = None,
|
|
70
|
+
scopes: list[str] | tuple[str, ...] = (),
|
|
71
|
+
enabled: bool | Callable[[Any], bool] = True,
|
|
72
|
+
**metadata: Any,
|
|
73
|
+
):
|
|
74
|
+
return _decorate(
|
|
75
|
+
"prompt",
|
|
76
|
+
name=name,
|
|
77
|
+
description=description,
|
|
78
|
+
scopes=tuple(scopes),
|
|
79
|
+
enabled=enabled,
|
|
80
|
+
metadata=metadata,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_exposure(value: Any) -> Exposure | None:
|
|
85
|
+
return getattr(value, MARKER, None) or getattr(getattr(value, "__func__", None), MARKER, None)
|
appmcp/definitions.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Exposure:
|
|
10
|
+
kind: str
|
|
11
|
+
name: str | None = None
|
|
12
|
+
description: str | None = None
|
|
13
|
+
uri: str | None = None
|
|
14
|
+
scopes: tuple[str, ...] = ()
|
|
15
|
+
rate_limit: str | None = None
|
|
16
|
+
read_only: bool = False
|
|
17
|
+
confirmation_required: bool = False
|
|
18
|
+
enabled: bool | Callable[[Any], bool] = True
|
|
19
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Definition:
|
|
24
|
+
kind: str
|
|
25
|
+
name: str
|
|
26
|
+
callable: Callable[..., Any]
|
|
27
|
+
description: str = ""
|
|
28
|
+
uri: str | None = None
|
|
29
|
+
scopes: tuple[str, ...] = ()
|
|
30
|
+
rate_limit: str | None = None
|
|
31
|
+
read_only: bool = False
|
|
32
|
+
confirmation_required: bool = False
|
|
33
|
+
enabled: bool | Callable[[Any], bool] = True
|
|
34
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
ToolDefinition = Definition
|
|
38
|
+
ResourceDefinition = Definition
|
|
39
|
+
PromptDefinition = Definition
|