restmcp 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.
pythia/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ from pythia.datasource import DataSource
2
+ from pythia.entity import Entity
3
+ from pythia.repository import Repository
4
+ from pythia.service import Service
5
+ from pythia.endpoint import Endpoint
6
+ from pythia.server import Server
7
+ from pythia.logging import Logger
8
+ from pythia.exceptions import ValidationError, NotFoundError
9
+ from pythia.types import McpDefinition
10
+
11
+ __all__ = [
12
+ "DataSource",
13
+ "Entity",
14
+ "Repository",
15
+ "Service",
16
+ "Endpoint",
17
+ "Server",
18
+ "Logger",
19
+ "ValidationError",
20
+ "NotFoundError",
21
+ "McpDefinition",
22
+ ]
pythia/cli/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ import click
2
+ from pythia.cli.new import new_command
3
+
4
+
5
+ @click.group()
6
+ def app():
7
+ """pythia — framework for building MCP servers."""
8
+ pass
9
+
10
+
11
+ app.add_command(new_command, name="new")
pythia/cli/new.py ADDED
@@ -0,0 +1,65 @@
1
+ import os
2
+ import click
3
+
4
+ TEMPLATE_DIRS = ["datasource", "models", "repositories", "services", "tools", "urls"]
5
+
6
+ MAIN_PY = """\
7
+ from pythia import Server
8
+ import urls # auto-discovery
9
+
10
+ server = Server.get_instance()
11
+
12
+ if __name__ == "__main__":
13
+ server.start()
14
+ """
15
+
16
+ URLS_INIT = """\
17
+ import importlib
18
+ import pkgutil
19
+
20
+ for _info in pkgutil.iter_modules(__path__):
21
+ importlib.import_module(f"{__name__}.{_info.name}")
22
+ """
23
+
24
+ PYPROJECT = """\
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [project]
30
+ name = "{name}"
31
+ version = "0.1.0"
32
+ requires-python = ">=3.11"
33
+ dependencies = [
34
+ "pythia",
35
+ ]
36
+ """
37
+
38
+
39
+ @click.command()
40
+ @click.argument("name")
41
+ def new_command(name: str):
42
+ """Create a new MCP server with the pythia structure."""
43
+ base = os.path.join(os.getcwd(), name)
44
+
45
+ if os.path.exists(base):
46
+ click.echo(f"Error: directory '{name}' already exists.", err=True)
47
+ raise SystemExit(1)
48
+
49
+ os.makedirs(base)
50
+
51
+ for d in TEMPLATE_DIRS:
52
+ os.makedirs(os.path.join(base, d))
53
+ open(os.path.join(base, d, "__init__.py"), "w").close()
54
+
55
+ with open(os.path.join(base, "urls", "__init__.py"), "w") as f:
56
+ f.write(URLS_INIT)
57
+
58
+ with open(os.path.join(base, "main.py"), "w") as f:
59
+ f.write(MAIN_PY)
60
+
61
+ with open(os.path.join(base, "pyproject.toml"), "w") as f:
62
+ f.write(PYPROJECT.format(name=name))
63
+
64
+ click.echo(f"Project '{name}' created successfully.")
65
+ click.echo(f" cd {name} && pip install -e . && python main.py")
pythia/datasource.py ADDED
@@ -0,0 +1,18 @@
1
+ from abc import ABC
2
+
3
+
4
+ class DataSource(ABC):
5
+ """External data provider (HTTP, DB, file). Subclass names must end with 'DataSource'."""
6
+
7
+ def __init_subclass__(cls, **kwargs):
8
+ super().__init_subclass__(**kwargs)
9
+ if not cls.__name__.endswith("DataSource"):
10
+ raise TypeError(
11
+ f"DataSource subclasses must end with 'DataSource' "
12
+ f"(got: '{cls.__name__}'). Rename to '{cls.__name__}DataSource'."
13
+ )
14
+
15
+ def __new__(cls, *args, **kwargs):
16
+ if cls is DataSource:
17
+ raise TypeError("DataSource is abstract and cannot be instantiated directly.")
18
+ return super().__new__(cls)
pythia/endpoint.py ADDED
@@ -0,0 +1,140 @@
1
+ import asyncio
2
+ import inspect
3
+ from abc import ABC
4
+
5
+ from fastapi import Depends, Request
6
+ from fastapi.responses import JSONResponse
7
+
8
+ from pythia.exceptions import PythiaException, ValidationError
9
+
10
+
11
+ def _validate_mcp_definition(cls_name: str, mcp_def: object) -> None:
12
+ if not isinstance(mcp_def, dict):
13
+ raise TypeError(
14
+ f"{cls_name}: mcp_definition must be a dict, got {type(mcp_def).__name__}"
15
+ )
16
+ for key in ("name", "description"):
17
+ val = mcp_def.get(key)
18
+ if not isinstance(val, str) or not val.strip():
19
+ raise TypeError(
20
+ f"{cls_name}: mcp_definition['{key}'] must be a non-empty string"
21
+ )
22
+ params = mcp_def.get("parameters")
23
+ if params is not None:
24
+ if not isinstance(params, dict):
25
+ raise TypeError(
26
+ f"{cls_name}: mcp_definition['parameters'] must be a dict"
27
+ )
28
+ props = params.get("properties")
29
+ if props is not None and not isinstance(props, dict):
30
+ raise TypeError(
31
+ f"{cls_name}: mcp_definition['parameters']['properties'] must be a dict"
32
+ )
33
+
34
+
35
+ class Endpoint(ABC):
36
+ """HTTP + MCP endpoint. Subclasses auto-register on class definition when url, method, mcp_definition, and callback are all set."""
37
+
38
+ disabled: bool = False
39
+
40
+ def __init_subclass__(cls, **kwargs):
41
+ super().__init_subclass__(**kwargs)
42
+ if not cls.__name__.endswith("Endpoint"):
43
+ raise TypeError(
44
+ f"Endpoint subclasses must end with 'Endpoint' "
45
+ f"(got: '{cls.__name__}'). Rename to '{cls.__name__}Endpoint'."
46
+ )
47
+
48
+ if getattr(cls, "disabled", False):
49
+ return
50
+
51
+ _required = ("url", "method", "mcp_definition", "callback")
52
+ if all(vars(cls).get(attr) for attr in _required):
53
+ _validate_mcp_definition(cls.__name__, vars(cls)["mcp_definition"])
54
+ try:
55
+ cls()
56
+ except TypeError as e:
57
+ raise TypeError(
58
+ f"{cls.__name__}: auto-registration failed. "
59
+ f"Endpoint subclasses must not define __init__ with parameters — "
60
+ f"use Service/Repository for dependencies. Original error: {e}"
61
+ ) from e
62
+
63
+ async def _callback(self, request: Request):
64
+ try:
65
+ try:
66
+ data = await request.json()
67
+ except Exception:
68
+ data = {}
69
+ data = data or {}
70
+
71
+ valid_params = self.mcp_definition.get("parameters", {}).get("properties", {})
72
+ parameters = {}
73
+
74
+ for key, value in data.items():
75
+ if key not in valid_params:
76
+ raise ValidationError(f"Invalid parameter: {key}")
77
+ parameters[key] = value
78
+
79
+ if inspect.iscoroutinefunction(self.callback):
80
+ result = await self.callback(**parameters)
81
+ else:
82
+ loop = asyncio.get_running_loop()
83
+ result = await loop.run_in_executor(
84
+ None, lambda: self.callback(**parameters)
85
+ )
86
+
87
+ return JSONResponse({
88
+ "tool": self.mcp_definition["name"],
89
+ "result": result,
90
+ "success": True,
91
+ })
92
+
93
+ except PythiaException as e:
94
+ return JSONResponse({
95
+ "error": e.message,
96
+ "tool": self.mcp_definition["name"],
97
+ "success": False,
98
+ "error_type": e.__class__.__name__,
99
+ }, status_code=e.status_code)
100
+
101
+ except Exception as e:
102
+ return JSONResponse({
103
+ "error": str(e),
104
+ "tool": self.mcp_definition["name"],
105
+ "success": False,
106
+ "error_type": "InternalServerError",
107
+ }, status_code=500)
108
+
109
+ def __init__(self):
110
+ from pythia.server import Server
111
+ from pythia.rest import _auth_dependency
112
+
113
+ self.mcp_definition = getattr(self, "mcp_definition", None)
114
+ if not self.mcp_definition:
115
+ raise ValueError(f"{self.__class__.__name__}: mcp_definition is required")
116
+
117
+ self.method = getattr(self, "method", None)
118
+ if not self.method:
119
+ raise ValueError(f"{self.__class__.__name__}: method is required")
120
+
121
+ self.url = getattr(self, "url", None)
122
+ if not self.url:
123
+ raise ValueError(f"{self.__class__.__name__}: url is required")
124
+
125
+ if not getattr(self, "callback", None):
126
+ raise ValueError(f"{self.__class__.__name__}: callback is required")
127
+
128
+ endpoint_self = self
129
+
130
+ async def route_handler(request: Request):
131
+ return await endpoint_self._callback(request)
132
+
133
+ server = Server.get_instance()
134
+ server.app.add_api_route(
135
+ self.url,
136
+ route_handler,
137
+ methods=[self.method],
138
+ dependencies=[Depends(_auth_dependency)],
139
+ )
140
+ server.register_url_handler(self)
pythia/entity.py ADDED
@@ -0,0 +1,13 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Entity(BaseModel):
5
+ """Pydantic model for data returned by a DataSource. Subclass names must end with 'Entity'."""
6
+
7
+ def __init_subclass__(cls, **kwargs):
8
+ super().__init_subclass__(**kwargs)
9
+ if not cls.__name__.endswith("Entity"):
10
+ raise TypeError(
11
+ f"Entity subclasses must end with 'Entity' "
12
+ f"(got: '{cls.__name__}'). Rename to '{cls.__name__}Entity'."
13
+ )
pythia/exceptions.py ADDED
@@ -0,0 +1,17 @@
1
+ class PythiaException(Exception):
2
+ """Base exception for pythia. Carries an HTTP status code alongside the message."""
3
+
4
+ def __init__(self, message: str, status_code: int = 500):
5
+ self.message = message
6
+ self.status_code = status_code
7
+ super().__init__(message)
8
+
9
+
10
+ class ValidationError(PythiaException):
11
+ def __init__(self, message: str):
12
+ super().__init__(message, status_code=400)
13
+
14
+
15
+ class NotFoundError(PythiaException):
16
+ def __init__(self, message: str):
17
+ super().__init__(message, status_code=404)
pythia/logging.py ADDED
@@ -0,0 +1,35 @@
1
+ import logging
2
+ import os
3
+
4
+
5
+ class Logger:
6
+ """Thin wrapper over Python's logging module. Log level configurable via LOG_LEVEL env var (default: INFO)."""
7
+
8
+ def __init__(self, name: str):
9
+ level_name = os.getenv("LOG_LEVEL", "INFO").upper()
10
+ level = getattr(logging, level_name, logging.INFO)
11
+
12
+ self._logger = logging.getLogger(name)
13
+ self._logger.setLevel(level)
14
+
15
+ if not self._logger.handlers:
16
+ handler = logging.StreamHandler()
17
+ handler.setLevel(level)
18
+ formatter = logging.Formatter(
19
+ "[%(asctime)s] %(levelname)s %(name)s — %(message)s",
20
+ datefmt="%Y-%m-%d %H:%M:%S",
21
+ )
22
+ handler.setFormatter(formatter)
23
+ self._logger.addHandler(handler)
24
+
25
+ def info(self, msg: str, *args, **kwargs):
26
+ self._logger.info(msg, *args, **kwargs)
27
+
28
+ def warning(self, msg: str, *args, **kwargs):
29
+ self._logger.warning(msg, *args, **kwargs)
30
+
31
+ def error(self, msg: str, *args, **kwargs):
32
+ self._logger.error(msg, *args, **kwargs)
33
+
34
+ def debug(self, msg: str, *args, **kwargs):
35
+ self._logger.debug(msg, *args, **kwargs)
pythia/mcp.py ADDED
@@ -0,0 +1,66 @@
1
+ from typing import Any, Dict, List, Optional
2
+
3
+
4
+ _JSON_TO_PYTHON = {
5
+ "string": str,
6
+ "integer": int,
7
+ "number": float,
8
+ "boolean": bool,
9
+ "object": dict,
10
+ }
11
+
12
+
13
+ class McpApp:
14
+ """Builds a FastMCP instance from registered url_handlers, mapping mcp_definition to typed pydantic tool wrappers."""
15
+
16
+ def build(self, url_handlers: List[Any]):
17
+ from fastmcp import FastMCP
18
+
19
+ mcp = FastMCP("pythia")
20
+ for handler in url_handlers:
21
+ self._register_tool(mcp, handler)
22
+ return mcp
23
+
24
+ def _register_tool(self, mcp: Any, handler: Any):
25
+ from pydantic import Field, create_model
26
+ import inspect
27
+
28
+ def_dict = handler.mcp_definition
29
+ name = def_dict["name"]
30
+ description = def_dict["description"]
31
+ properties = def_dict.get("parameters", {}).get("properties", {})
32
+
33
+ pydantic_fields = {}
34
+ for prop_name, prop_data in properties.items():
35
+ ptype = prop_data.get("type")
36
+ if ptype == "array":
37
+ item_ptype = prop_data.get("items", {}).get("type", "string")
38
+ item_type = _JSON_TO_PYTHON.get(item_ptype, str)
39
+ py_type = List[item_type]
40
+ elif ptype == "object":
41
+ py_type = Dict[str, Any]
42
+ else:
43
+ py_type = _JSON_TO_PYTHON.get(ptype, str)
44
+
45
+ default_val = prop_data.get("default", ...)
46
+ if default_val is None:
47
+ py_type = Optional[py_type]
48
+
49
+ pydantic_fields[prop_name] = (
50
+ py_type,
51
+ Field(default=default_val, description=prop_data.get("description", "")),
52
+ )
53
+
54
+ ModelClass = create_model(f"{name}_args", **pydantic_fields)
55
+
56
+ def tool_wrapper(args: ModelClass) -> dict:
57
+ return handler.callback(**args.model_dump())
58
+
59
+ tool_wrapper.__name__ = name
60
+ tool_wrapper.__doc__ = description
61
+ sig = inspect.signature(tool_wrapper)
62
+ new_param = inspect.Parameter(
63
+ "args", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=ModelClass
64
+ )
65
+ tool_wrapper.__signature__ = sig.replace(parameters=(new_param,))
66
+ mcp.add_tool(tool_wrapper)
pythia/repository.py ADDED
@@ -0,0 +1,35 @@
1
+ import copy
2
+ from abc import ABC, abstractmethod
3
+
4
+ from pythia.datasource import DataSource
5
+
6
+
7
+ class Repository(ABC):
8
+ """Data access layer. Wraps a DataSource and exposes a get() method. Subclass names must end with 'Repository'."""
9
+
10
+ def __init_subclass__(cls, **kwargs):
11
+ super().__init_subclass__(**kwargs)
12
+ if not cls.__name__.endswith("Repository"):
13
+ raise TypeError(
14
+ f"Repository subclasses must end with 'Repository' "
15
+ f"(got: '{cls.__name__}'). Rename to '{cls.__name__}Repository'."
16
+ )
17
+
18
+ def __init__(self, data_source: DataSource = None):
19
+ if data_source is not None:
20
+ resolved = data_source
21
+ else:
22
+ default = getattr(type(self), "data_source", None)
23
+ resolved = copy.copy(default)
24
+
25
+ if not resolved:
26
+ raise ValueError(f"{self.__class__.__name__}: data_source is required")
27
+ if not isinstance(resolved, DataSource):
28
+ raise ValueError(
29
+ f"{self.__class__.__name__}: data_source must be a DataSource instance"
30
+ )
31
+ self.data_source = resolved
32
+
33
+ @abstractmethod
34
+ def get(self, **kwargs):
35
+ pass
pythia/rest.py ADDED
@@ -0,0 +1,63 @@
1
+ import datetime as dt
2
+ import os
3
+ from typing import Any, List
4
+
5
+ from fastapi import FastAPI, HTTPException, Request
6
+ from starlette.middleware.cors import CORSMiddleware
7
+
8
+
9
+ def _validate_api_key(raw_key: str) -> bool:
10
+ env_keys = os.getenv("AUTH_API_KEY", "")
11
+ return bool(raw_key) and raw_key in [k.strip() for k in env_keys.split(",") if k.strip()]
12
+
13
+
14
+ def _auth_dependency(request: Request):
15
+ if not os.getenv("AUTH_API_KEY"):
16
+ return
17
+ auth_header = request.headers.get("Authorization")
18
+ api_key = auth_header.split(" ")[1] if auth_header and auth_header.startswith("Bearer ") else None
19
+ if not api_key or not _validate_api_key(api_key):
20
+ raise HTTPException(status_code=401, detail="Unauthorized")
21
+
22
+
23
+ class RestApp:
24
+ """FastAPI application with CORS, auth dependency, and default routes (/health, /mcp/tools)."""
25
+
26
+ def __init__(self):
27
+ self.app = FastAPI()
28
+ cors_origins = [o.strip() for o in os.getenv("CORS_ORIGINS", "*").split(",")]
29
+ self.app.add_middleware(
30
+ CORSMiddleware,
31
+ allow_origins=cors_origins,
32
+ allow_methods=["*"],
33
+ allow_headers=["*"],
34
+ )
35
+ self.url_handlers: List[Any] = []
36
+ self._setup_default_routes()
37
+
38
+ def _setup_default_routes(self):
39
+ @self.app.get("/mcp/tools")
40
+ def list_tools():
41
+ return {
42
+ "tools": [
43
+ {
44
+ "name": h.mcp_definition["name"],
45
+ "description": h.mcp_definition["description"],
46
+ "parameters": h.mcp_definition["parameters"],
47
+ "returns": h.mcp_definition.get("returns", {}),
48
+ }
49
+ for h in self.url_handlers
50
+ ],
51
+ "server": "pythia",
52
+ "version": "0.1.0",
53
+ }
54
+
55
+ @self.app.get("/health")
56
+ def health_check():
57
+ return {
58
+ "status": "healthy",
59
+ "timestamp": dt.datetime.utcnow().isoformat(),
60
+ }
61
+
62
+ def register_handler(self, handler: Any):
63
+ self.url_handlers.append(handler)
pythia/server.py ADDED
@@ -0,0 +1,51 @@
1
+ from typing import Any, Optional
2
+
3
+ from pythia.rest import RestApp
4
+ from pythia.mcp import McpApp
5
+
6
+
7
+ class Server:
8
+ """Singleton that composes RestApp and McpApp. Entry point for starting the server and accessing registered endpoints."""
9
+
10
+ _instance: Optional["Server"] = None
11
+
12
+ def __new__(cls):
13
+ if cls._instance is None:
14
+ cls._instance = super().__new__(cls)
15
+ cls._instance._initialized = False
16
+ return cls._instance
17
+
18
+ def __init__(self):
19
+ if self._initialized:
20
+ return
21
+ self._rest = RestApp()
22
+ self._mcp = McpApp()
23
+ self._initialized = True
24
+
25
+ @property
26
+ def app(self):
27
+ return self._rest.app
28
+
29
+ @property
30
+ def url_handlers(self):
31
+ return self._rest.url_handlers
32
+
33
+ def register_url_handler(self, handler: Any):
34
+ self._rest.register_handler(handler)
35
+
36
+ def start(self, host: str = "0.0.0.0", port: int = 5000, reload: bool = False):
37
+ import uvicorn
38
+ uvicorn.run(self.app, host=host, port=port, reload=reload)
39
+
40
+ def get_mcp(self):
41
+ return self._mcp.build(self.url_handlers)
42
+
43
+ @classmethod
44
+ def get_instance(cls) -> "Server":
45
+ if cls._instance is None:
46
+ cls._instance = cls()
47
+ return cls._instance
48
+
49
+ @classmethod
50
+ def _reset(cls):
51
+ cls._instance = None
pythia/service.py ADDED
@@ -0,0 +1,35 @@
1
+ import copy
2
+ from abc import ABC
3
+
4
+ from pythia.repository import Repository
5
+
6
+
7
+ class Service(ABC):
8
+ """Business logic layer. Auto-discovers and copies Repository class attributes with DI support. Subclass names must end with 'Service'."""
9
+
10
+ def __init_subclass__(cls, **kwargs):
11
+ super().__init_subclass__(**kwargs)
12
+ if not cls.__name__.endswith("Service"):
13
+ raise TypeError(
14
+ f"Service subclasses must end with 'Service' "
15
+ f"(got: '{cls.__name__}'). Rename to '{cls.__name__}Service'."
16
+ )
17
+ has_repo = any(
18
+ isinstance(v, Repository)
19
+ for klass in cls.__mro__
20
+ if klass not in (Service, object)
21
+ for v in vars(klass).values()
22
+ )
23
+ if not has_repo:
24
+ raise TypeError(
25
+ f"{cls.__name__}: must define at least one Repository instance "
26
+ f"as a class attribute."
27
+ )
28
+
29
+ def __init__(self, **overrides):
30
+ seen = set()
31
+ for klass in type(self).__mro__:
32
+ for name, value in vars(klass).items():
33
+ if name not in seen and isinstance(value, Repository):
34
+ seen.add(name)
35
+ setattr(self, name, overrides.get(name, copy.copy(value)))
pythia/types.py ADDED
@@ -0,0 +1,22 @@
1
+ from typing import Any, Dict, TypedDict
2
+
3
+
4
+ class McpProperty(TypedDict, total=False):
5
+ type: str
6
+ description: str
7
+ default: Any
8
+ items: Dict[str, Any]
9
+
10
+
11
+ class McpParameters(TypedDict, total=False):
12
+ properties: Dict[str, McpProperty]
13
+
14
+
15
+ class _McpDefinitionBase(TypedDict):
16
+ name: str
17
+ description: str
18
+
19
+
20
+ class McpDefinition(_McpDefinitionBase, total=False):
21
+ parameters: McpParameters
22
+ returns: Dict[str, Any]
@@ -0,0 +1,403 @@
1
+ Metadata-Version: 2.4
2
+ Name: restmcp
3
+ Version: 0.1.0
4
+ Summary: Python framework for building MCP servers with a layered architecture and REST compatibility
5
+ Project-URL: Homepage, https://github.com/JorgeHSantana/Pythia
6
+ Project-URL: Repository, https://github.com/JorgeHSantana/Pythia
7
+ Project-URL: Bug Tracker, https://github.com/JorgeHSantana/Pythia/issues
8
+ Author-email: Jorge Henrique Moreira Santana <jorge.henrique.moreira.santana@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai,async,fastapi,framework,llm,mcp,rest,server
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
21
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: click>=8.0
24
+ Requires-Dist: fastapi>=0.100
25
+ Requires-Dist: fastmcp>=2.0
26
+ Requires-Dist: pydantic>=2.0
27
+ Requires-Dist: uvicorn[standard]>=0.20
28
+ Provides-Extra: dev
29
+ Requires-Dist: httpx>=0.24; extra == 'dev'
30
+ Requires-Dist: pytest; extra == 'dev'
31
+ Requires-Dist: pytest-cov; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # restmcp
35
+
36
+ > One framework. MCP tools and REST endpoints, auto-registered.
37
+
38
+ Python framework for building **MCP servers** with a layered architecture and REST compatibility.
39
+ Annotated classes become MCP tools and HTTP endpoints: auto-registered, dependency-injected, sync/async agnostic.
40
+
41
+ ---
42
+
43
+ ## Architecture
44
+
45
+ ```mermaid
46
+ graph LR
47
+ LLM["🤖 LLM / Client"] -->|"HTTP or MCP"| EP["Endpoint"]
48
+ EP --> SV["Service"]
49
+ SV --> RP["Repository"]
50
+ RP --> DS["DataSource"]
51
+ DS --> EX[("External\nAPI / DB")]
52
+
53
+ style EP fill:#4f46e5,color:#fff,stroke:none
54
+ style SV fill:#7c3aed,color:#fff,stroke:none
55
+ style RP fill:#9333ea,color:#fff,stroke:none
56
+ style DS fill:#a855f7,color:#fff,stroke:none
57
+ ```
58
+
59
+ Each layer knows only the layer directly below it. Every class name is suffix-enforced at import time: a typo raises `TypeError` before the server starts.
60
+
61
+ ---
62
+
63
+ ## Installation
64
+
65
+ ```bash
66
+ pip install restmcp
67
+ ```
68
+
69
+ ---
70
+
71
+ ## Quick start
72
+
73
+ ```bash
74
+ pythia new my-server
75
+ cd my-server
76
+ pip install -e .
77
+ python main.py
78
+ ```
79
+
80
+ Generated structure:
81
+
82
+ ```
83
+ my-server/
84
+ ├── datasource/ # external connections (APIs, databases)
85
+ ├── models/ # domain entities (Pydantic)
86
+ ├── repositories/ # data access layer
87
+ ├── services/ # business logic
88
+ ├── tools/ # internal utilities
89
+ ├── urls/ # endpoint definitions (auto-discovery)
90
+ ├── main.py
91
+ └── pyproject.toml
92
+ ```
93
+
94
+ ---
95
+
96
+ ## How it works
97
+
98
+ ```mermaid
99
+ sequenceDiagram
100
+ participant C as Client / LLM
101
+ participant E as Endpoint
102
+ participant S as Service
103
+ participant R as Repository
104
+ participant D as DataSource
105
+
106
+ C->>E: POST /api/get-product {"product_id": "1"}
107
+ E->>S: service.execute(product_id="1")
108
+ S->>R: repo.get(product_id="1")
109
+ R->>D: data_source.fetch("1")
110
+ D-->>R: raw dict
111
+ R-->>S: ProductEntity
112
+ S-->>E: result dict
113
+ E-->>C: {"tool": "get_product", "result": {...}, "success": true}
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Base classes
119
+
120
+ ### `DataSource`
121
+
122
+ Abstracts the connection to an external data source (REST API, database, file).
123
+ **Rule:** class name must end with `DataSource`.
124
+
125
+ ```python
126
+ import httpx
127
+ from pythia import DataSource
128
+
129
+ class ProductApiDataSource(DataSource):
130
+ base_url = "https://api.example.com"
131
+
132
+ async def fetch(self, product_id: str) -> dict:
133
+ async with httpx.AsyncClient() as client:
134
+ r = await client.get(f"{self.base_url}/products/{product_id}")
135
+ r.raise_for_status()
136
+ return r.json()
137
+ ```
138
+
139
+ ---
140
+
141
+ ### `Entity`
142
+
143
+ Structured domain data backed by Pydantic. Automatic type validation.
144
+ **Rule:** class name must end with `Entity`.
145
+
146
+ ```python
147
+ from pythia import Entity
148
+
149
+ class ProductEntity(Entity):
150
+ id: str
151
+ name: str
152
+ price: float
153
+ ```
154
+
155
+ ---
156
+
157
+ ### `Repository`
158
+
159
+ Fetches data via a `DataSource` and returns `Entity` objects. One source, one data type.
160
+ **Rules:** name ends with `Repository`; must declare `data_source` as class attribute; must implement `get()`.
161
+
162
+ ```python
163
+ from pythia import Repository
164
+ from datasource.product_api import ProductApiDataSource
165
+ from models.product import ProductEntity
166
+
167
+ class ProductRepository(Repository):
168
+ data_source = ProductApiDataSource()
169
+
170
+ async def get(self, product_id: str) -> ProductEntity:
171
+ raw = await self.data_source.fetch(product_id)
172
+ return ProductEntity(**raw)
173
+ ```
174
+
175
+ **Dependency injection:**
176
+
177
+ ```python
178
+ repo = ProductRepository() # uses real DataSource
179
+ repo = ProductRepository(data_source=MockDataSource()) # injects mock for tests
180
+ ```
181
+
182
+ `Repository.__init__` uses `copy.copy()` of the class attribute: instances are always isolated.
183
+
184
+ ---
185
+
186
+ ### `Service`
187
+
188
+ Orchestrates business logic. Where joins, transformations, and multi-source rules live.
189
+ **Rules:** name ends with `Service`; must declare at least one `Repository` as class attribute.
190
+
191
+ ```python
192
+ from pythia import Service
193
+ from repositories.product import ProductRepository
194
+
195
+ class GetProductService(Service):
196
+ repo = ProductRepository()
197
+
198
+ async def execute(self, product_id: str) -> dict:
199
+ product = await self.repo.get(product_id=product_id)
200
+ return product.model_dump()
201
+ ```
202
+
203
+ **Dependency injection:**
204
+
205
+ ```python
206
+ svc = GetProductService() # production
207
+ svc = GetProductService(repo=MockRepository()) # test
208
+ ```
209
+
210
+ Repository class attributes are auto-discovered via MRO and isolated per instance.
211
+
212
+ ---
213
+
214
+ ### `Endpoint`
215
+
216
+ HTTP + MCP route. **Auto-registers on class definition**: no manual wiring needed.
217
+ **Rules:** name ends with `Endpoint`; must declare `mcp_definition`, `url`, `method`, and `callback`.
218
+
219
+ ```python
220
+ from pythia import Endpoint
221
+ from services.product import GetProductService
222
+
223
+ class GetProductEndpoint(Endpoint):
224
+ mcp_definition = {
225
+ "name": "get_product",
226
+ "description": "Returns a product by ID",
227
+ "parameters": {
228
+ "properties": {
229
+ "product_id": {"type": "string", "description": "Product ID"},
230
+ },
231
+ },
232
+ }
233
+ url = "/api/get-product"
234
+ method = "POST"
235
+
236
+ async def callback(self, product_id: str) -> dict:
237
+ return await GetProductService().execute(product_id)
238
+ ```
239
+
240
+ Defining the class is enough. The route is registered on the `Server` singleton the moment Python processes the class body.
241
+
242
+ **Disabling an endpoint:**
243
+
244
+ ```python
245
+ class GetProductEndpoint(Endpoint):
246
+ disabled = True # skips auto-registration; can still be instantiated manually
247
+ ...
248
+ ```
249
+
250
+ **Abstract base classes** (missing any required attribute) are never auto-registered:
251
+
252
+ ```python
253
+ class BaseAuthEndpoint(Endpoint):
254
+ method = "POST"
255
+ def callback(self, **kwargs): ...
256
+ # ↑ not registered: url and mcp_definition are missing
257
+
258
+ class GetUserEndpoint(BaseAuthEndpoint):
259
+ mcp_definition = { ... }
260
+ url = "/api/get-user"
261
+ # ↑ registered automatically: all required attributes present
262
+ ```
263
+
264
+ **Sync and async callbacks** are both supported: pythia detects and handles either:
265
+
266
+ ```python
267
+ # sync: runs in a thread pool, does not block the event loop
268
+ def callback(self, product_id: str) -> dict:
269
+ return requests.get(f"https://api.example.com/products/{product_id}").json()
270
+
271
+ # async: awaited directly; use asyncio.gather for parallel I/O
272
+ async def callback(self, product_id: str) -> dict:
273
+ async with httpx.AsyncClient() as client:
274
+ r = await client.get(f"https://api.example.com/products/{product_id}")
275
+ return r.json()
276
+ ```
277
+
278
+ **Response format:**
279
+
280
+ ```json
281
+ { "tool": "get_product", "result": { ... }, "success": true }
282
+ ```
283
+
284
+ ```json
285
+ { "tool": "get_product", "error": "not found", "error_type": "NotFoundError", "success": false }
286
+ ```
287
+
288
+ ---
289
+
290
+ ### `Server`
291
+
292
+ Singleton with dual-mode: HTTP via FastAPI/uvicorn or MCP protocol via FastMCP.
293
+
294
+ ```python
295
+ from pythia import Server
296
+ import urls # triggers auto-discovery of all endpoint modules
297
+
298
+ server = Server.get_instance()
299
+
300
+ if __name__ == "__main__":
301
+ server.start(host="0.0.0.0", port=5000)
302
+ ```
303
+
304
+ ```python
305
+ # MCP mode
306
+ mcp = server.get_mcp()
307
+ ```
308
+
309
+ **Built-in routes:**
310
+
311
+ | Route | Method | Auth required |
312
+ |-------|--------|---------------|
313
+ | `/health` | GET | No |
314
+ | `/mcp/tools` | GET | No |
315
+ | _your endpoints_ | POST | Yes (if `AUTH_API_KEY` is set) |
316
+
317
+ ---
318
+
319
+ ## Exceptions
320
+
321
+ Raised inside `callback`: caught by `Endpoint` and converted to HTTP responses automatically.
322
+
323
+ ```python
324
+ from pythia import ValidationError, NotFoundError
325
+
326
+ raise ValidationError("product_id is required") # → HTTP 400
327
+ raise NotFoundError("Product not found") # → HTTP 404
328
+ ```
329
+
330
+ ```mermaid
331
+ graph TD
332
+ PythiaException --> ValidationError["ValidationError (400)"]
333
+ PythiaException --> NotFoundError["NotFoundError (404)"]
334
+ ```
335
+
336
+ ---
337
+
338
+ ## Testing with injection
339
+
340
+ ```python
341
+ from pythia import DataSource
342
+ from repositories.product import ProductRepository
343
+ from services.product import GetProductService
344
+
345
+ class FakeProductApiDataSource(DataSource):
346
+ async def fetch(self, product_id: str) -> dict:
347
+ return {"id": product_id, "name": "Test Widget", "price": 1.99}
348
+
349
+ def test_get_product():
350
+ svc = GetProductService(repo=ProductRepository(data_source=FakeProductApiDataSource()))
351
+ result = svc.execute(product_id="1")
352
+ assert result["name"] == "Test Widget"
353
+ ```
354
+
355
+ ---
356
+
357
+ ## Environment variables
358
+
359
+ | Variable | Default | Description |
360
+ |----------|---------|-------------|
361
+ | `AUTH_API_KEY` | _(disabled)_ | Bearer token. Multiple keys supported comma-separated. |
362
+ | `CORS_ORIGINS` | `*` | Allowed origins. Multiple values supported comma-separated. |
363
+ | `LOG_LEVEL` | `INFO` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`. |
364
+
365
+ ---
366
+
367
+ ## Naming conventions
368
+
369
+ All base classes enforce a suffix. Violating it raises `TypeError` at import time: before the server starts.
370
+
371
+ | Base class | Required suffix | Example |
372
+ |------------|----------------|---------|
373
+ | `DataSource` | `*DataSource` | `ProductApiDataSource` |
374
+ | `Entity` | `*Entity` | `ProductEntity` |
375
+ | `Repository` | `*Repository` | `ProductRepository` |
376
+ | `Service` | `*Service` | `GetProductService` |
377
+ | `Endpoint` | `*Endpoint` | `GetProductEndpoint` |
378
+
379
+ ---
380
+
381
+ ## Dependencies
382
+
383
+ ```
384
+ fastapi >= 0.100
385
+ uvicorn >= 0.20
386
+ fastmcp >= 2.0
387
+ pydantic >= 2.0
388
+ click >= 8.0
389
+ ```
390
+
391
+ ---
392
+
393
+ ## Author
394
+
395
+ **Jorge Henrique Moreira Santana**
396
+ Electrical Engineer, Postgraduate in Artificial Intelligence
397
+ [LinkedIn](https://www.linkedin.com/in/jorge-santana-b246874a/) · jorge.henrique.moreira.santana@gmail.com
398
+
399
+ ---
400
+
401
+ ## License
402
+
403
+ [MIT](LICENSE)
@@ -0,0 +1,19 @@
1
+ pythia/__init__.py,sha256=-qBANeCi5gF2DpvgN4JHcQtLWlphZlxvFKFBCN8dOVM,543
2
+ pythia/datasource.py,sha256=yrq96uiFzF4NBoxgHXuxDOd9MNsqwxsVKGUnRS1CqUA,669
3
+ pythia/endpoint.py,sha256=IOt8xSSPDMKTycjqJLPK_u38N_64AK4YYXDNVVM9ZW4,5023
4
+ pythia/entity.py,sha256=yGnit4sD18-n0h-4Umv3QvTf5bo6yFhPlu2IyRaOrwI,474
5
+ pythia/exceptions.py,sha256=sgdbx6PVbYgxXgm1-4I8GUAxgSfPx9IfWlYL11rACRU,549
6
+ pythia/logging.py,sha256=iwGP3p-NrBcvFEL2iEn-ztUHu7e7h3v52aaUBUMVSg8,1174
7
+ pythia/mcp.py,sha256=qkCy18Vpm4Y5Y1ZtyWmRHyUKb7edX6YgGbTWsLWuVOU,2205
8
+ pythia/repository.py,sha256=JXd4T1yCrQJ1tWcEbB12BNv94kfbi_lWq1YA9kJsA1M,1204
9
+ pythia/rest.py,sha256=36L29-K003ZjZnrPe5ytV0BZDpSoPbrCQzSefUyXzig,2143
10
+ pythia/server.py,sha256=n5V43cj8pQ3cqdClqgZ0IA5xUZ6bGGd4wbjxVuFQhuU,1342
11
+ pythia/service.py,sha256=Nflwls_7YNBFLasFmcBL1e0bGZOdBLZobJ4-CDjGTrs,1284
12
+ pythia/types.py,sha256=S9seymj7EzMtRVEOPe8MqkZDwYwZ66yFY4YykO8JPJ8,437
13
+ pythia/cli/__init__.py,sha256=lnmyKZ0xDWdckiWuinpRG1eNnd8-RwEHtpwHX9j0nrY,189
14
+ pythia/cli/new.py,sha256=DajdF4VpEszs5-JO-gn6QGvsNNJsEL9ZoYLn11zP_fg,1504
15
+ restmcp-0.1.0.dist-info/METADATA,sha256=GhMc3KR9N6HuAxC6Gg486xvDSqNB7-Is7WNWkeo7tkk,10892
16
+ restmcp-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
17
+ restmcp-0.1.0.dist-info/entry_points.txt,sha256=2yMZUEAaNAPdOuTgqC-wfHLDKbrlR3u8JkMcoqd6YoE,42
18
+ restmcp-0.1.0.dist-info/licenses/LICENSE,sha256=3KqaiOmu_5URYxkqg7EPIQGTd4ckqsu7_P5oCBQ7yuY,1070
19
+ restmcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pythia = pythia.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JorgeHSantana
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.