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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ # Namespace package for AgentRuntime
2
+
@@ -0,0 +1,4 @@
1
+ from .decorators import tool, mount_tools # noqa: F401
2
+ from .runtime import run, make_server, load_config # noqa: F401
3
+ from .schemas import emit_json_shape, emit_flat_shape, build_schemas # noqa: F401
4
+
@@ -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,9 @@
1
+ fastmcp
2
+ pydantic>=2
3
+ starlette>=0.37
4
+ uvicorn[standard]>=0.30
5
+ opentelemetry-api>=1.25
6
+ opentelemetry-sdk>=1.25
7
+ opentelemetry-exporter-otlp>=1.25
8
+ PyYAML>=6
9
+ httpx>=0.25