agentruntime-mcp 0.0.1__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.
- agentruntime_mcp-0.0.1/PKG-INFO +62 -0
- agentruntime_mcp-0.0.1/README.md +45 -0
- agentruntime_mcp-0.0.1/pyproject.toml +27 -0
- agentruntime_mcp-0.0.1/setup.cfg +4 -0
- agentruntime_mcp-0.0.1/src/agentruntime/__init__.py +2 -0
- agentruntime_mcp-0.0.1/src/agentruntime/mcp/__init__.py +4 -0
- agentruntime_mcp-0.0.1/src/agentruntime/mcp/decorators.py +74 -0
- agentruntime_mcp-0.0.1/src/agentruntime/mcp/generators/openapi_to_mcp.py +48 -0
- agentruntime_mcp-0.0.1/src/agentruntime/mcp/middleware.py +207 -0
- agentruntime_mcp-0.0.1/src/agentruntime/mcp/proxy.py +76 -0
- agentruntime_mcp-0.0.1/src/agentruntime/mcp/runtime.py +47 -0
- agentruntime_mcp-0.0.1/src/agentruntime/mcp/schemas.py +170 -0
- agentruntime_mcp-0.0.1/src/agentruntime_mcp.egg-info/PKG-INFO +62 -0
- agentruntime_mcp-0.0.1/src/agentruntime_mcp.egg-info/SOURCES.txt +15 -0
- agentruntime_mcp-0.0.1/src/agentruntime_mcp.egg-info/dependency_links.txt +1 -0
- agentruntime_mcp-0.0.1/src/agentruntime_mcp.egg-info/requires.txt +9 -0
- agentruntime_mcp-0.0.1/src/agentruntime_mcp.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentruntime-mcp
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: AgentRuntime MCP SDK (decorators, runtime, middleware, schemas, proxy, generators)
|
|
5
|
+
Author: AgentRuntime
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: fastmcp
|
|
9
|
+
Requires-Dist: pydantic>=2
|
|
10
|
+
Requires-Dist: starlette>=0.37
|
|
11
|
+
Requires-Dist: uvicorn[standard]>=0.30
|
|
12
|
+
Requires-Dist: opentelemetry-api>=1.25
|
|
13
|
+
Requires-Dist: opentelemetry-sdk>=1.25
|
|
14
|
+
Requires-Dist: opentelemetry-exporter-otlp>=1.25
|
|
15
|
+
Requires-Dist: PyYAML>=6
|
|
16
|
+
Requires-Dist: httpx>=0.25
|
|
17
|
+
|
|
18
|
+
# AgentRuntime MCP SDK (Python)
|
|
19
|
+
|
|
20
|
+
Opinionated SDK for building MCP agents with FastMCP.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
```bash
|
|
24
|
+
pip install agentruntime-mcp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Minimal example
|
|
28
|
+
```python
|
|
29
|
+
from pydantic import BaseModel, Field
|
|
30
|
+
from agentruntime.mcp.decorators import tool
|
|
31
|
+
from agentruntime.mcp.runtime import run
|
|
32
|
+
|
|
33
|
+
class In(BaseModel):
|
|
34
|
+
a: float = Field(...)
|
|
35
|
+
b: float = Field(...)
|
|
36
|
+
|
|
37
|
+
class Out(BaseModel):
|
|
38
|
+
result: float
|
|
39
|
+
expression: str
|
|
40
|
+
|
|
41
|
+
@tool(name="add", input_model=In, output_model=Out)
|
|
42
|
+
def add(a: float, b: float) -> Out:
|
|
43
|
+
return Out(result=a+b, expression=f"{a} + {b}")
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
run("config.yaml")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Config
|
|
50
|
+
- `config.yaml` controls server host/port, auth mode, and tracing.
|
|
51
|
+
- Env overrides: `HOST`, `PORT`, `MCP_AUTH_MODE`.
|
|
52
|
+
|
|
53
|
+
Auth modes
|
|
54
|
+
- `none`: no auth
|
|
55
|
+
- `token`: `Authorization: Bearer <token>` or `X-MCP-Token`; dev fallback `?auth_token=` if `ALLOW_QUERY_TOKEN=true`
|
|
56
|
+
- `hmac`: headers `X-MCP-KeyId`, `X-MCP-Timestamp` (unix seconds), `X-MCP-Signature` (hex(HMAC-SHA256(secret, `${ts}\n${method}\n${path}`)))
|
|
57
|
+
|
|
58
|
+
## Proxy (library)
|
|
59
|
+
```python
|
|
60
|
+
from agentruntime.mcp.proxy import run_proxy
|
|
61
|
+
run_proxy(target_url="http://127.0.0.1:8000/mcp", overlay_file="tools.yaml", host="127.0.0.1", port=8010)
|
|
62
|
+
```
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# AgentRuntime MCP SDK (Python)
|
|
2
|
+
|
|
3
|
+
Opinionated SDK for building MCP agents with FastMCP.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
```bash
|
|
7
|
+
pip install agentruntime-mcp
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Minimal example
|
|
11
|
+
```python
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
from agentruntime.mcp.decorators import tool
|
|
14
|
+
from agentruntime.mcp.runtime import run
|
|
15
|
+
|
|
16
|
+
class In(BaseModel):
|
|
17
|
+
a: float = Field(...)
|
|
18
|
+
b: float = Field(...)
|
|
19
|
+
|
|
20
|
+
class Out(BaseModel):
|
|
21
|
+
result: float
|
|
22
|
+
expression: str
|
|
23
|
+
|
|
24
|
+
@tool(name="add", input_model=In, output_model=Out)
|
|
25
|
+
def add(a: float, b: float) -> Out:
|
|
26
|
+
return Out(result=a+b, expression=f"{a} + {b}")
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
run("config.yaml")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Config
|
|
33
|
+
- `config.yaml` controls server host/port, auth mode, and tracing.
|
|
34
|
+
- Env overrides: `HOST`, `PORT`, `MCP_AUTH_MODE`.
|
|
35
|
+
|
|
36
|
+
Auth modes
|
|
37
|
+
- `none`: no auth
|
|
38
|
+
- `token`: `Authorization: Bearer <token>` or `X-MCP-Token`; dev fallback `?auth_token=` if `ALLOW_QUERY_TOKEN=true`
|
|
39
|
+
- `hmac`: headers `X-MCP-KeyId`, `X-MCP-Timestamp` (unix seconds), `X-MCP-Signature` (hex(HMAC-SHA256(secret, `${ts}\n${method}\n${path}`)))
|
|
40
|
+
|
|
41
|
+
## Proxy (library)
|
|
42
|
+
```python
|
|
43
|
+
from agentruntime.mcp.proxy import run_proxy
|
|
44
|
+
run_proxy(target_url="http://127.0.0.1:8000/mcp", overlay_file="tools.yaml", host="127.0.0.1", port=8010)
|
|
45
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentruntime-mcp"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "AgentRuntime MCP SDK (decorators, runtime, middleware, schemas, proxy, generators)"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
authors = [{ name = "AgentRuntime" }]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"fastmcp",
|
|
14
|
+
"pydantic>=2",
|
|
15
|
+
"starlette>=0.37",
|
|
16
|
+
"uvicorn[standard]>=0.30",
|
|
17
|
+
"opentelemetry-api>=1.25",
|
|
18
|
+
"opentelemetry-sdk>=1.25",
|
|
19
|
+
"opentelemetry-exporter-otlp>=1.25",
|
|
20
|
+
"PyYAML>=6",
|
|
21
|
+
"httpx>=0.25",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
package-dir = {"" = "src"}
|
|
26
|
+
|
|
27
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Optional, Type, Any, Dict, List, Tuple
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from fastmcp import FastMCP
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ToolRegistry:
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self.entries: List[Dict[str, Any]] = []
|
|
11
|
+
|
|
12
|
+
def register(
|
|
13
|
+
self,
|
|
14
|
+
name: str,
|
|
15
|
+
func: Callable[..., Any],
|
|
16
|
+
input_model: Optional[Type[BaseModel]],
|
|
17
|
+
output_model: Optional[Type[BaseModel]],
|
|
18
|
+
description: Optional[str],
|
|
19
|
+
) -> None:
|
|
20
|
+
self.entries.append(
|
|
21
|
+
{
|
|
22
|
+
"name": name,
|
|
23
|
+
"func": func,
|
|
24
|
+
"in_model": input_model,
|
|
25
|
+
"out_model": output_model,
|
|
26
|
+
"desc": (description or "").strip() or None,
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
registry = ToolRegistry()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def tool(
|
|
35
|
+
name: str,
|
|
36
|
+
input_model: Optional[Type[BaseModel]] = None,
|
|
37
|
+
output_model: Optional[Type[BaseModel]] = None,
|
|
38
|
+
description: Optional[str] = None,
|
|
39
|
+
):
|
|
40
|
+
def deco(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
41
|
+
registry.register(name, func, input_model, output_model, description or getattr(func, "__doc__", None))
|
|
42
|
+
return func
|
|
43
|
+
|
|
44
|
+
return deco
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def mount_tools(
|
|
48
|
+
mcp: FastMCP,
|
|
49
|
+
schema_builder: Callable[[Optional[type], Optional[type]], Tuple[Dict[str, Any], Dict[str, Any]]],
|
|
50
|
+
) -> List[Tuple[Dict[str, Any], Callable[..., Any]]]:
|
|
51
|
+
"""Expose tools from the registry on the given FastMCP app and add tools/list."""
|
|
52
|
+
exposed: List[Tuple[Dict[str, Any], Callable[..., Any]]] = []
|
|
53
|
+
for e in registry.entries:
|
|
54
|
+
exposed_fn = mcp.tool(name=e["name"])(e["func"]) # type: ignore[arg-type]
|
|
55
|
+
exposed.append((e, exposed_fn))
|
|
56
|
+
|
|
57
|
+
@mcp.tool(name="list_tools")
|
|
58
|
+
def list_tools() -> Dict[str, Any]:
|
|
59
|
+
items: List[Dict[str, Any]] = []
|
|
60
|
+
for e, _ in exposed:
|
|
61
|
+
s_in, s_out = schema_builder(e.get("in_model"), e.get("out_model"))
|
|
62
|
+
items.append(
|
|
63
|
+
{
|
|
64
|
+
"name": e["name"],
|
|
65
|
+
"description": e.get("desc"),
|
|
66
|
+
"inputSchema": s_in,
|
|
67
|
+
"outputSchema": s_out,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
return {"tools": items}
|
|
71
|
+
|
|
72
|
+
return exposed
|
|
73
|
+
|
|
74
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
TEMPLATE_HEADER = """from fastmcp import FastMCP
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
app = FastMCP(name="OpenAPI_MCP")
|
|
12
|
+
|
|
13
|
+
def _client():
|
|
14
|
+
return httpx.Client(timeout=10)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def generate(openapi_file: str, output_py: str) -> None:
|
|
19
|
+
with open(openapi_file, "r", encoding="utf-8") as f:
|
|
20
|
+
spec: Dict[str, Any] = json.load(f)
|
|
21
|
+
|
|
22
|
+
code = [TEMPLATE_HEADER]
|
|
23
|
+
base_url = spec.get("servers", [{}])[0].get("url", "")
|
|
24
|
+
|
|
25
|
+
paths = spec.get("paths", {})
|
|
26
|
+
for path, methods in paths.items():
|
|
27
|
+
for method, op in methods.items():
|
|
28
|
+
if method.lower() not in {"get", "post"}:
|
|
29
|
+
continue
|
|
30
|
+
op_id = op.get("operationId") or (method + "_" + path.strip("/").replace("/", "_").replace("-", "_") or "root")
|
|
31
|
+
fn_name = f"tool_{op_id}"
|
|
32
|
+
url_expr = base_url.rstrip("/") + path
|
|
33
|
+
code.append(f"\n@app.tool(name=\"{op_id}\")\ndef {fn_name}(**kwargs):\n \"\"\"Generated from OpenAPI {method.upper()} {path}\n kwargs are sent as JSON for POST or as params for GET\n \"\"\"\n with _client() as c:\n if '{method.lower()}' == 'get':\n r = c.get(\"{url_expr}\", params=kwargs)\n else:\n r = c.post(\"{url_expr}\", json=kwargs)\n r.raise_for_status()\n return {{'structuredContent': r.json()}}\n")
|
|
34
|
+
|
|
35
|
+
os.makedirs(os.path.dirname(output_py), exist_ok=True)
|
|
36
|
+
with open(output_py, "w", encoding="utf-8") as f:
|
|
37
|
+
f.write("\n".join(code))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
import argparse
|
|
42
|
+
p = argparse.ArgumentParser()
|
|
43
|
+
p.add_argument("-i", "--input", required=True, help="openapi.json path")
|
|
44
|
+
p.add_argument("-o", "--output", default="generated/openapi_tools.py")
|
|
45
|
+
args = p.parse_args()
|
|
46
|
+
generate(args.input, args.output)
|
|
47
|
+
|
|
48
|
+
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import traceback
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
import time
|
|
7
|
+
import hmac
|
|
8
|
+
import hashlib
|
|
9
|
+
|
|
10
|
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---- OpenTelemetry (OTLP over HTTP to local collector/jaeger) ----
|
|
14
|
+
trace: Any = None
|
|
15
|
+
TraceContextTextMapPropagator: Any = None
|
|
16
|
+
tracer = None
|
|
17
|
+
try:
|
|
18
|
+
from opentelemetry import trace # type: ignore
|
|
19
|
+
from opentelemetry.trace.propagation.tracecontext import ( # type: ignore
|
|
20
|
+
TraceContextTextMapPropagator,
|
|
21
|
+
)
|
|
22
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( # type: ignore
|
|
23
|
+
OTLPSpanExporter,
|
|
24
|
+
)
|
|
25
|
+
from opentelemetry.sdk.trace import TracerProvider # type: ignore
|
|
26
|
+
from opentelemetry.sdk.resources import Resource # type: ignore
|
|
27
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor # type: ignore
|
|
28
|
+
|
|
29
|
+
otlp_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:4318")
|
|
30
|
+
resource = Resource.create({"service.name": os.getenv("OTEL_SERVICE_NAME", "fastmcp-enhanced")})
|
|
31
|
+
provider = TracerProvider(resource=resource)
|
|
32
|
+
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint=f"{otlp_endpoint}/v1/traces"))
|
|
33
|
+
provider.add_span_processor(processor)
|
|
34
|
+
trace.set_tracer_provider(provider)
|
|
35
|
+
tracer = trace.get_tracer(os.getenv("OTEL_SERVICE_NAME", "fastmcp-enhanced"))
|
|
36
|
+
|
|
37
|
+
print("OpenTelemetry setup complete")
|
|
38
|
+
except Exception:
|
|
39
|
+
print("Error setting up OpenTelemetry:")
|
|
40
|
+
print(traceback.format_exc())
|
|
41
|
+
tracer = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CustomHeaderMiddleware(Middleware):
|
|
45
|
+
async def on_request(self, context: MiddlewareContext, call_next):
|
|
46
|
+
method = getattr(context, "method", "UNKNOWN")
|
|
47
|
+
tool_name = getattr(getattr(context, "message", None), "name", None)
|
|
48
|
+
|
|
49
|
+
# If OpenTelemetry isn't available, skip tracing entirely
|
|
50
|
+
if tracer is None or "trace" not in globals() or trace is None or "TraceContextTextMapPropagator" not in globals() or TraceContextTextMapPropagator is None: # type: ignore[name-defined]
|
|
51
|
+
return await call_next(context)
|
|
52
|
+
|
|
53
|
+
def _extract_parent_ctx(req) -> Any:
|
|
54
|
+
propagator = TraceContextTextMapPropagator()
|
|
55
|
+
|
|
56
|
+
# Try headers first
|
|
57
|
+
try:
|
|
58
|
+
hdr_ctx = propagator.extract(req.headers)
|
|
59
|
+
hdr_span = trace.get_current_span(hdr_ctx).get_span_context()
|
|
60
|
+
if hdr_span and getattr(hdr_span, "is_valid", False):
|
|
61
|
+
return hdr_ctx
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
# Fallback to query parameters (?traceparent=&tracestate=)
|
|
66
|
+
try:
|
|
67
|
+
qp = getattr(req, "query_params", None)
|
|
68
|
+
if qp:
|
|
69
|
+
carrier: Dict[str, str] = {}
|
|
70
|
+
tp = qp.get("traceparent")
|
|
71
|
+
ts = qp.get("tracestate")
|
|
72
|
+
if tp:
|
|
73
|
+
carrier["traceparent"] = tp
|
|
74
|
+
if ts:
|
|
75
|
+
carrier["tracestate"] = ts
|
|
76
|
+
if carrier:
|
|
77
|
+
q_ctx = propagator.extract(carrier)
|
|
78
|
+
q_span = trace.get_current_span(q_ctx).get_span_context()
|
|
79
|
+
if q_span and getattr(q_span, "is_valid", False):
|
|
80
|
+
return q_ctx
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
parent_ctx: Any = None
|
|
87
|
+
try:
|
|
88
|
+
fast_ctx = getattr(context, "fastmcp_context", None)
|
|
89
|
+
if fast_ctx is not None and getattr(fast_ctx, "request_context", None) is not None:
|
|
90
|
+
req = getattr(fast_ctx.request_context, "request", None)
|
|
91
|
+
if req is not None:
|
|
92
|
+
parent_ctx = _extract_parent_ctx(req)
|
|
93
|
+
except Exception:
|
|
94
|
+
parent_ctx = None
|
|
95
|
+
|
|
96
|
+
span_name = f"mcp.{method}"
|
|
97
|
+
if tool_name:
|
|
98
|
+
span_name = f"mcp.tools.{tool_name}"
|
|
99
|
+
|
|
100
|
+
with tracer.start_as_current_span(span_name, context=parent_ctx) as span: # type: ignore[attr-defined]
|
|
101
|
+
span.set_attribute("mcp.method", method)
|
|
102
|
+
if tool_name:
|
|
103
|
+
span.set_attribute("mcp.tool", tool_name)
|
|
104
|
+
result = await call_next(context)
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class AuthTokenMiddleware(Middleware):
|
|
109
|
+
async def on_request(self, context: MiddlewareContext, call_next):
|
|
110
|
+
expected_token = os.getenv("MCP_AUTH_TOKEN")
|
|
111
|
+
# Only enforce if an expected token is configured
|
|
112
|
+
if expected_token:
|
|
113
|
+
token: Optional[str] = None
|
|
114
|
+
try:
|
|
115
|
+
fast_ctx = getattr(context, "fastmcp_context", None)
|
|
116
|
+
if fast_ctx is not None and getattr(fast_ctx, "request_context", None) is not None:
|
|
117
|
+
req = getattr(fast_ctx.request_context, "request", None)
|
|
118
|
+
if req is not None:
|
|
119
|
+
# 1) Prefer Authorization: Bearer <token>
|
|
120
|
+
auth = None
|
|
121
|
+
try:
|
|
122
|
+
auth = req.headers.get("Authorization")
|
|
123
|
+
except Exception:
|
|
124
|
+
auth = None
|
|
125
|
+
if auth and isinstance(auth, str) and auth.lower().startswith("bearer "):
|
|
126
|
+
token = auth.split(" ", 1)[1].strip()
|
|
127
|
+
# 2) Or X-MCP-Token header
|
|
128
|
+
if not token:
|
|
129
|
+
try:
|
|
130
|
+
token = req.headers.get("X-MCP-Token")
|
|
131
|
+
except Exception:
|
|
132
|
+
token = None
|
|
133
|
+
# 3) Dev fallback: query param if allowed
|
|
134
|
+
allow_query = os.getenv("ALLOW_QUERY_TOKEN", "true").lower() == "true"
|
|
135
|
+
if not token and allow_query and hasattr(req, "query_params"):
|
|
136
|
+
token = req.query_params.get("auth_token")
|
|
137
|
+
except Exception:
|
|
138
|
+
token = None
|
|
139
|
+
|
|
140
|
+
if not token or token != expected_token:
|
|
141
|
+
raise PermissionError("invalid or missing auth token")
|
|
142
|
+
|
|
143
|
+
return await call_next(context)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Simple HMAC auth: client sends X-MCP-KeyId, X-MCP-Timestamp, X-MCP-Signature.
|
|
147
|
+
# Signature is hex(hmac_sha256(secret, f"{ts}\n{method}\n{path}")) with up to 5 minutes skew.
|
|
148
|
+
class HMACAuthMiddleware(Middleware):
|
|
149
|
+
def _load_secret(self, key_id: str) -> Optional[str]:
|
|
150
|
+
# Single key via env
|
|
151
|
+
single_id = os.getenv("MCP_HMAC_KEY_ID")
|
|
152
|
+
single_secret = os.getenv("MCP_HMAC_SECRET")
|
|
153
|
+
if single_id and single_secret and key_id == single_id:
|
|
154
|
+
return single_secret
|
|
155
|
+
# TODO: Optionally support JSON map in MCP_HMAC_KEYS_JSON later
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
async def on_request(self, context: MiddlewareContext, call_next):
|
|
159
|
+
# If HMAC is configured via env, enforce; otherwise allow through
|
|
160
|
+
configured = bool(os.getenv("MCP_HMAC_KEY_ID") and os.getenv("MCP_HMAC_SECRET"))
|
|
161
|
+
if not configured:
|
|
162
|
+
return await call_next(context)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
fast_ctx = getattr(context, "fastmcp_context", None)
|
|
166
|
+
if fast_ctx is None or getattr(fast_ctx, "request_context", None) is None:
|
|
167
|
+
raise PermissionError("missing request context")
|
|
168
|
+
req = getattr(fast_ctx.request_context, "request", None)
|
|
169
|
+
if req is None:
|
|
170
|
+
raise PermissionError("missing request")
|
|
171
|
+
|
|
172
|
+
# Extract headers
|
|
173
|
+
key_id = req.headers.get("X-MCP-KeyId") or req.headers.get("X-MCP-Key")
|
|
174
|
+
ts = req.headers.get("X-MCP-Timestamp")
|
|
175
|
+
sig = req.headers.get("X-MCP-Signature")
|
|
176
|
+
if not key_id or not ts or not sig:
|
|
177
|
+
raise PermissionError("missing hmac headers")
|
|
178
|
+
|
|
179
|
+
# Timestamp freshness (skew up to 300s)
|
|
180
|
+
try:
|
|
181
|
+
ts_int = int(ts)
|
|
182
|
+
except Exception:
|
|
183
|
+
raise PermissionError("invalid timestamp")
|
|
184
|
+
now = int(time.time())
|
|
185
|
+
if abs(now - ts_int) > 300:
|
|
186
|
+
raise PermissionError("stale timestamp")
|
|
187
|
+
|
|
188
|
+
secret = self._load_secret(key_id)
|
|
189
|
+
if not secret:
|
|
190
|
+
raise PermissionError("unknown key id")
|
|
191
|
+
|
|
192
|
+
method = (getattr(req, "method", "POST") or "POST").upper()
|
|
193
|
+
path = getattr(getattr(req, "url", None), "path", "/mcp") or "/mcp"
|
|
194
|
+
base = f"{ts}\n{method}\n{path}"
|
|
195
|
+
expected = hmac.new(secret.encode("utf-8"), base.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
196
|
+
|
|
197
|
+
if not hmac.compare_digest(expected, sig):
|
|
198
|
+
raise PermissionError("invalid signature")
|
|
199
|
+
|
|
200
|
+
except PermissionError:
|
|
201
|
+
raise
|
|
202
|
+
except Exception:
|
|
203
|
+
raise PermissionError("hmac verification failed")
|
|
204
|
+
|
|
205
|
+
return await call_next(context)
|
|
206
|
+
|
|
207
|
+
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from fastmcp import FastMCP
|
|
7
|
+
import httpx
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _overlay(overlay_file: Optional[str]) -> Dict[str, Any]:
|
|
12
|
+
if not overlay_file:
|
|
13
|
+
return {}
|
|
14
|
+
try:
|
|
15
|
+
with open(overlay_file, "r", encoding="utf-8") as f:
|
|
16
|
+
return yaml.safe_load(f) or {}
|
|
17
|
+
except Exception:
|
|
18
|
+
return {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _post(target_url: str, payload: Dict[str, Any], bearer_token: Optional[str]) -> Dict[str, Any]:
|
|
22
|
+
headers = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
|
|
23
|
+
if bearer_token:
|
|
24
|
+
headers["Authorization"] = f"Bearer {bearer_token}"
|
|
25
|
+
r = httpx.post(target_url, headers=headers, content=json.dumps(payload), timeout=15)
|
|
26
|
+
r.raise_for_status()
|
|
27
|
+
return r.json()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_proxy_app(target_url: str, overlay_file: Optional[str] = None, bearer_token: Optional[str] = None) -> FastMCP:
|
|
31
|
+
app = FastMCP(name="MCPProxy")
|
|
32
|
+
|
|
33
|
+
@app.tool(name="list_tools")
|
|
34
|
+
def list_tools() -> Dict[str, Any]:
|
|
35
|
+
if not target_url:
|
|
36
|
+
return {"tools": []}
|
|
37
|
+
res = _post(target_url, {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, bearer_token)
|
|
38
|
+
tools: List[Dict[str, Any]] = res.get("result", {}).get("tools", [])
|
|
39
|
+
ov = _overlay(overlay_file)
|
|
40
|
+
ov_map = {t.get("name"): t for t in ov.get("tools", [])}
|
|
41
|
+
merged: List[Dict[str, Any]] = []
|
|
42
|
+
for t in tools:
|
|
43
|
+
name = t.get("name")
|
|
44
|
+
if name in ov_map:
|
|
45
|
+
mt = {**t}
|
|
46
|
+
o = ov_map[name]
|
|
47
|
+
if o.get("description"):
|
|
48
|
+
mt["description"] = o["description"]
|
|
49
|
+
if o.get("inputSchema"):
|
|
50
|
+
mt["inputSchema"] = o["inputSchema"]
|
|
51
|
+
if o.get("outputSchema"):
|
|
52
|
+
mt["outputSchema"] = o["outputSchema"]
|
|
53
|
+
merged.append(mt)
|
|
54
|
+
else:
|
|
55
|
+
merged.append(t)
|
|
56
|
+
return {"tools": merged}
|
|
57
|
+
|
|
58
|
+
@app.tool(name="call")
|
|
59
|
+
def call(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
60
|
+
if not target_url:
|
|
61
|
+
raise ValueError("Proxy target not configured")
|
|
62
|
+
res = _post(
|
|
63
|
+
target_url,
|
|
64
|
+
{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}},
|
|
65
|
+
bearer_token,
|
|
66
|
+
)
|
|
67
|
+
return res.get("result", {})
|
|
68
|
+
|
|
69
|
+
return app
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_proxy(target_url: str, overlay_file: Optional[str] = None, bearer_token: Optional[str] = None, host: str = "127.0.0.1", port: int = 8010) -> None:
|
|
73
|
+
app = build_proxy_app(target_url, overlay_file, bearer_token)
|
|
74
|
+
app.run(transport="http", host=host, port=port, stateless_http=True)
|
|
75
|
+
|
|
76
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import yaml
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
from fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from .decorators import mount_tools
|
|
9
|
+
from .schemas import build_schemas
|
|
10
|
+
from .middleware import CustomHeaderMiddleware, AuthTokenMiddleware, HMACAuthMiddleware
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_config(path: str = "config.yaml") -> Dict[str, Any]:
|
|
14
|
+
if not os.path.exists(path):
|
|
15
|
+
return {}
|
|
16
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
17
|
+
return yaml.safe_load(f) or {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def make_server(config_path: str = "config.yaml") -> FastMCP:
|
|
21
|
+
cfg = load_config(config_path)
|
|
22
|
+
name = cfg.get("server", {}).get("name", "MCPServer")
|
|
23
|
+
app = FastMCP(name=name)
|
|
24
|
+
|
|
25
|
+
# Middleware toggles
|
|
26
|
+
if cfg.get("tracing", {}).get("enabled", True):
|
|
27
|
+
app.add_middleware(CustomHeaderMiddleware())
|
|
28
|
+
auth_mode = os.getenv("MCP_AUTH_MODE") or cfg.get("auth", {}).get("mode", "token")
|
|
29
|
+
if auth_mode == "token":
|
|
30
|
+
app.add_middleware(AuthTokenMiddleware())
|
|
31
|
+
elif auth_mode == "hmac":
|
|
32
|
+
app.add_middleware(HMACAuthMiddleware())
|
|
33
|
+
|
|
34
|
+
# Mount tools from decorators registry; add list_tools
|
|
35
|
+
mount_tools(app, build_schemas)
|
|
36
|
+
return app
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run(config_path: str = "config.yaml") -> None:
|
|
40
|
+
app = make_server(config_path)
|
|
41
|
+
cfg = load_config(config_path)
|
|
42
|
+
host = os.getenv("HOST") or cfg.get("server", {}).get("host", "127.0.0.1")
|
|
43
|
+
port = int(os.getenv("PORT") or cfg.get("server", {}).get("port", 8000))
|
|
44
|
+
stateless = cfg.get("server", {}).get("stateless_http", True)
|
|
45
|
+
app.run(transport="http", host=host, port=port, stateless_http=stateless)
|
|
46
|
+
|
|
47
|
+
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional, Type, List, get_args, get_origin
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _map_type_json(py_type: Any) -> Dict[str, Any]:
|
|
9
|
+
origin = get_origin(py_type)
|
|
10
|
+
args = get_args(py_type)
|
|
11
|
+
if origin in (list, List):
|
|
12
|
+
item_type = args[0] if args else Any
|
|
13
|
+
return {"type": "array", "items": _map_type_json(item_type)}
|
|
14
|
+
if isinstance(py_type, type) and issubclass(py_type, Enum):
|
|
15
|
+
try:
|
|
16
|
+
values = [m.value for m in py_type]
|
|
17
|
+
except Exception:
|
|
18
|
+
values = [str(m) for m in py_type]
|
|
19
|
+
return {"type": "enum", "enum": values}
|
|
20
|
+
if py_type is str:
|
|
21
|
+
return {"type": "string"}
|
|
22
|
+
if py_type is int:
|
|
23
|
+
return {"type": "integer"}
|
|
24
|
+
if py_type is float:
|
|
25
|
+
return {"type": "float"}
|
|
26
|
+
if py_type is bool:
|
|
27
|
+
return {"type": "boolean"}
|
|
28
|
+
if py_type is dict:
|
|
29
|
+
return {"type": "object"}
|
|
30
|
+
return {"type": "string"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _json_constraints(prop: Dict[str, Any], field_info: Any) -> None:
|
|
34
|
+
min_len = getattr(field_info, "min_length", None) or getattr(field_info, "min_items", None)
|
|
35
|
+
max_len = getattr(field_info, "max_length", None) or getattr(field_info, "max_items", None)
|
|
36
|
+
if prop.get("type") == "string":
|
|
37
|
+
if min_len is not None:
|
|
38
|
+
prop["minLength"] = int(min_len)
|
|
39
|
+
if max_len is not None:
|
|
40
|
+
prop["maxLength"] = int(max_len)
|
|
41
|
+
ge = getattr(field_info, "ge", None)
|
|
42
|
+
le = getattr(field_info, "le", None)
|
|
43
|
+
gt = getattr(field_info, "gt", None)
|
|
44
|
+
lt = getattr(field_info, "lt", None)
|
|
45
|
+
if prop.get("type") in {"integer", "float", "number"}:
|
|
46
|
+
if ge is not None:
|
|
47
|
+
prop["minimum"] = float(ge)
|
|
48
|
+
elif gt is not None:
|
|
49
|
+
prop["minimum"] = float(gt)
|
|
50
|
+
if le is not None:
|
|
51
|
+
prop["maximum"] = float(le)
|
|
52
|
+
elif lt is not None:
|
|
53
|
+
prop["maximum"] = float(lt)
|
|
54
|
+
if prop.get("type") == "array":
|
|
55
|
+
items = prop.get("items", {})
|
|
56
|
+
if items.get("type") == "string":
|
|
57
|
+
if min_len is not None:
|
|
58
|
+
items["minLength"] = int(min_len)
|
|
59
|
+
if max_len is not None:
|
|
60
|
+
items["maxLength"] = int(max_len)
|
|
61
|
+
if items.get("type") in {"integer", "float", "number"}:
|
|
62
|
+
if ge is not None:
|
|
63
|
+
items["minimum"] = float(ge)
|
|
64
|
+
elif gt is not None:
|
|
65
|
+
items["minimum"] = float(gt)
|
|
66
|
+
if le is not None:
|
|
67
|
+
items["maximum"] = float(le)
|
|
68
|
+
elif lt is not None:
|
|
69
|
+
items["maximum"] = float(lt)
|
|
70
|
+
prop["items"] = items
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def emit_json_shape(model_cls: Optional[Type[BaseModel]]) -> Dict[str, Any]:
|
|
74
|
+
if not model_cls:
|
|
75
|
+
return {"properties": {}}
|
|
76
|
+
|
|
77
|
+
# Pydantic v2 vs v1 compatibility
|
|
78
|
+
if hasattr(model_cls, "model_fields"):
|
|
79
|
+
fields = model_cls.model_fields # type: ignore[attr-defined]
|
|
80
|
+
def _iter_fields():
|
|
81
|
+
for name, f in fields.items():
|
|
82
|
+
yield name, getattr(f, "annotation", Any), f
|
|
83
|
+
is_required = lambda f: getattr(f, "is_required", lambda: getattr(f, "required", False))()
|
|
84
|
+
get_info = lambda f: getattr(f, "field_info", f)
|
|
85
|
+
else:
|
|
86
|
+
fields = getattr(model_cls, "__fields__", {}) # type: ignore[assignment]
|
|
87
|
+
def _iter_fields():
|
|
88
|
+
for name, f in fields.items():
|
|
89
|
+
yield name, getattr(f, "type_", Any), f
|
|
90
|
+
is_required = lambda f: getattr(f, "required", False)
|
|
91
|
+
get_info = lambda f: getattr(f, "field_info", f)
|
|
92
|
+
|
|
93
|
+
props: Dict[str, Any] = {}
|
|
94
|
+
req: List[str] = []
|
|
95
|
+
for name, ann, f in _iter_fields():
|
|
96
|
+
prop = _map_type_json(ann)
|
|
97
|
+
info = get_info(f)
|
|
98
|
+
desc = getattr(info, "description", None)
|
|
99
|
+
if desc:
|
|
100
|
+
prop["description"] = desc
|
|
101
|
+
_json_constraints(prop, info)
|
|
102
|
+
# Alias support
|
|
103
|
+
alias = getattr(info, "alias", None)
|
|
104
|
+
json_name = alias or name
|
|
105
|
+
props[json_name] = prop
|
|
106
|
+
if is_required(f):
|
|
107
|
+
req.append(json_name)
|
|
108
|
+
out: Dict[str, Any] = {"properties": props}
|
|
109
|
+
if req:
|
|
110
|
+
out["required"] = req
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def emit_flat_shape(model_cls: Optional[Type[BaseModel]]) -> Dict[str, Any]:
|
|
115
|
+
if not model_cls:
|
|
116
|
+
return {}
|
|
117
|
+
|
|
118
|
+
if hasattr(model_cls, "model_fields"):
|
|
119
|
+
fields = model_cls.model_fields # type: ignore[attr-defined]
|
|
120
|
+
def _iter_fields():
|
|
121
|
+
for name, f in fields.items():
|
|
122
|
+
yield name, getattr(f, "annotation", Any), f
|
|
123
|
+
is_required = lambda f: getattr(f, "is_required", lambda: getattr(f, "required", False))()
|
|
124
|
+
get_info = lambda f: getattr(f, "field_info", f)
|
|
125
|
+
else:
|
|
126
|
+
fields = getattr(model_cls, "__fields__", {})
|
|
127
|
+
def _iter_fields():
|
|
128
|
+
for name, f in fields.items():
|
|
129
|
+
yield name, getattr(f, "type_", Any), f
|
|
130
|
+
is_required = lambda f: getattr(f, "required", False)
|
|
131
|
+
get_info = lambda f: getattr(f, "field_info", f)
|
|
132
|
+
|
|
133
|
+
props: Dict[str, Any] = {}
|
|
134
|
+
for name, ann, f in _iter_fields():
|
|
135
|
+
v = _map_type_json(ann)
|
|
136
|
+
direct: Dict[str, Any] = {"type": v.get("type", "string")}
|
|
137
|
+
info = get_info(f)
|
|
138
|
+
desc = getattr(info, "description", None)
|
|
139
|
+
if desc:
|
|
140
|
+
direct["description"] = desc
|
|
141
|
+
# For arrays promote element type, mark isArray
|
|
142
|
+
if v.get("type") == "array":
|
|
143
|
+
items = v.get("items", {})
|
|
144
|
+
direct["type"] = items.get("type", "string")
|
|
145
|
+
direct["isArray"] = True
|
|
146
|
+
# Constraints minimal set
|
|
147
|
+
min_len = getattr(info, "min_length", None)
|
|
148
|
+
max_len = getattr(info, "max_length", None)
|
|
149
|
+
if min_len is not None:
|
|
150
|
+
direct["minLength"] = int(min_len)
|
|
151
|
+
if max_len is not None:
|
|
152
|
+
direct["maxLength"] = int(max_len)
|
|
153
|
+
ge = getattr(info, "ge", None)
|
|
154
|
+
le = getattr(info, "le", None)
|
|
155
|
+
if ge is not None:
|
|
156
|
+
direct["minimum"] = float(ge)
|
|
157
|
+
if le is not None:
|
|
158
|
+
direct["maximum"] = float(le)
|
|
159
|
+
if is_required(f):
|
|
160
|
+
direct["required"] = True
|
|
161
|
+
alias = getattr(info, "alias", None)
|
|
162
|
+
json_name = alias or name
|
|
163
|
+
props[json_name] = direct
|
|
164
|
+
return props
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_schemas(in_model: Optional[Type[BaseModel]], out_model: Optional[Type[BaseModel]]):
|
|
168
|
+
return emit_json_shape(in_model), emit_json_shape(out_model)
|
|
169
|
+
|
|
170
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentruntime-mcp
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: AgentRuntime MCP SDK (decorators, runtime, middleware, schemas, proxy, generators)
|
|
5
|
+
Author: AgentRuntime
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: fastmcp
|
|
9
|
+
Requires-Dist: pydantic>=2
|
|
10
|
+
Requires-Dist: starlette>=0.37
|
|
11
|
+
Requires-Dist: uvicorn[standard]>=0.30
|
|
12
|
+
Requires-Dist: opentelemetry-api>=1.25
|
|
13
|
+
Requires-Dist: opentelemetry-sdk>=1.25
|
|
14
|
+
Requires-Dist: opentelemetry-exporter-otlp>=1.25
|
|
15
|
+
Requires-Dist: PyYAML>=6
|
|
16
|
+
Requires-Dist: httpx>=0.25
|
|
17
|
+
|
|
18
|
+
# AgentRuntime MCP SDK (Python)
|
|
19
|
+
|
|
20
|
+
Opinionated SDK for building MCP agents with FastMCP.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
```bash
|
|
24
|
+
pip install agentruntime-mcp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Minimal example
|
|
28
|
+
```python
|
|
29
|
+
from pydantic import BaseModel, Field
|
|
30
|
+
from agentruntime.mcp.decorators import tool
|
|
31
|
+
from agentruntime.mcp.runtime import run
|
|
32
|
+
|
|
33
|
+
class In(BaseModel):
|
|
34
|
+
a: float = Field(...)
|
|
35
|
+
b: float = Field(...)
|
|
36
|
+
|
|
37
|
+
class Out(BaseModel):
|
|
38
|
+
result: float
|
|
39
|
+
expression: str
|
|
40
|
+
|
|
41
|
+
@tool(name="add", input_model=In, output_model=Out)
|
|
42
|
+
def add(a: float, b: float) -> Out:
|
|
43
|
+
return Out(result=a+b, expression=f"{a} + {b}")
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
run("config.yaml")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Config
|
|
50
|
+
- `config.yaml` controls server host/port, auth mode, and tracing.
|
|
51
|
+
- Env overrides: `HOST`, `PORT`, `MCP_AUTH_MODE`.
|
|
52
|
+
|
|
53
|
+
Auth modes
|
|
54
|
+
- `none`: no auth
|
|
55
|
+
- `token`: `Authorization: Bearer <token>` or `X-MCP-Token`; dev fallback `?auth_token=` if `ALLOW_QUERY_TOKEN=true`
|
|
56
|
+
- `hmac`: headers `X-MCP-KeyId`, `X-MCP-Timestamp` (unix seconds), `X-MCP-Signature` (hex(HMAC-SHA256(secret, `${ts}\n${method}\n${path}`)))
|
|
57
|
+
|
|
58
|
+
## Proxy (library)
|
|
59
|
+
```python
|
|
60
|
+
from agentruntime.mcp.proxy import run_proxy
|
|
61
|
+
run_proxy(target_url="http://127.0.0.1:8000/mcp", overlay_file="tools.yaml", host="127.0.0.1", port=8010)
|
|
62
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/agentruntime/__init__.py
|
|
4
|
+
src/agentruntime/mcp/__init__.py
|
|
5
|
+
src/agentruntime/mcp/decorators.py
|
|
6
|
+
src/agentruntime/mcp/middleware.py
|
|
7
|
+
src/agentruntime/mcp/proxy.py
|
|
8
|
+
src/agentruntime/mcp/runtime.py
|
|
9
|
+
src/agentruntime/mcp/schemas.py
|
|
10
|
+
src/agentruntime/mcp/generators/openapi_to_mcp.py
|
|
11
|
+
src/agentruntime_mcp.egg-info/PKG-INFO
|
|
12
|
+
src/agentruntime_mcp.egg-info/SOURCES.txt
|
|
13
|
+
src/agentruntime_mcp.egg-info/dependency_links.txt
|
|
14
|
+
src/agentruntime_mcp.egg-info/requires.txt
|
|
15
|
+
src/agentruntime_mcp.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentruntime
|