godoo-mcp-server 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.3
2
+ Name: godoo-mcp-server
3
+ Version: 0.1.0
4
+ Summary: Stateless MCP server for Odoo — raw primitives + skill resources
5
+ License: LGPL-3.0-or-later
6
+ Requires-Dist: fastmcp>=3.3,<4
7
+ Requires-Dist: godoo-client>=0.2,<0.3
8
+ Requires-Dist: cachetools>=7,<8
9
+ Requires-Python: >=3.14
10
+ Description-Content-Type: text/markdown
11
+
12
+ # godoo-mcp-server
13
+
14
+ Stateless MCP server for Odoo — raw primitives + skill resources.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install godoo-mcp-server
20
+ ```
21
+
22
+ Or with Docker:
23
+
24
+ ```bash
25
+ docker run -e ODOO_URL=https://your.odoo.instance \
26
+ -e ODOO_DATABASE=mydb \
27
+ -e ODOO_USERNAME=admin \
28
+ -e ODOO_PASSWORD=secret \
29
+ -p 8000:8000 \
30
+ ghcr.io/godoo-dev/godoo-mcp-server:latest
31
+ ```
32
+
33
+ ## Configuration
34
+
35
+ The server resolves Odoo credentials from request headers or environment variables. Headers take precedence over environment variables.
36
+
37
+ | Credential | Header | Environment Variable |
38
+ |-----------|--------|---------------------|
39
+ | Odoo URL | `X-Odoo-Url` | `ODOO_URL` |
40
+ | Database | `X-Odoo-Database` | `ODOO_DATABASE` |
41
+ | Username | `X-Odoo-Username` | `ODOO_USERNAME` |
42
+ | Password | `X-Odoo-Password` | `ODOO_PASSWORD` |
43
+
44
+ If neither headers nor environment variables are set, the server returns a credential error.
45
+
46
+ **Server bind address:** Defaults to `0.0.0.0:8000`. Override with `HOST` and `PORT` environment variables.
47
+
48
+ > **Note:** If you override `PORT`, you must also update the Docker `HEALTHCHECK` to match the new port.
49
+
50
+ ## Security: Header Trust Model
51
+
52
+ `godoo-mcp-server` authenticates Odoo users via `X-Odoo-Username` and
53
+ `X-Odoo-Password` request headers (or `ODOO_USERNAME` / `ODOO_PASSWORD`
54
+ environment variables). **This means any caller who can reach the server
55
+ can impersonate any Odoo user by setting these headers.**
56
+
57
+ This is intentional for self-hosted / trusted-network deployments. In
58
+ production, you MUST deploy a header-stripping reverse proxy in front of
59
+ the server that enforces identity. [`godoo-proxy`](https://github.com/godoo-dev/godoo-proxy)
60
+ is the SaaS-safe gateway for this role — it implements OAuth 2.1 / SSO
61
+ and injects verified per-user credentials before forwarding to
62
+ `godoo-mcp-server`.
63
+
64
+ If you expose `godoo-mcp-server` directly on a public network without a
65
+ trusted proxy, any network peer gains full Odoo impersonation capability.
66
+
67
+ ## Running E2E tests locally
68
+
69
+ **Prerequisites:**
70
+
71
+ - Docker (for the Odoo testcontainer)
72
+ - `claude` CLI on PATH (Claude Code)
73
+ - Claude auth (API key or active OAuth session)
74
+ - Python 3.14 + `uv`
75
+
76
+ **Command:**
77
+
78
+ ```bash
79
+ uv run pytest -m e2e -s -v
80
+ ```
81
+
82
+ > **Cost warning:** Each run makes a real, billable Claude API call.
83
+ > Keep the suite small and only run when needed.
84
+
85
+ The test skips automatically when Docker or the `claude` CLI is absent — this is a clean skip, not an error.
86
+
87
+ The default `uv run pytest` excludes e2e tests automatically via the `not e2e` `addopts` filter.
@@ -0,0 +1,76 @@
1
+ # godoo-mcp-server
2
+
3
+ Stateless MCP server for Odoo — raw primitives + skill resources.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install godoo-mcp-server
9
+ ```
10
+
11
+ Or with Docker:
12
+
13
+ ```bash
14
+ docker run -e ODOO_URL=https://your.odoo.instance \
15
+ -e ODOO_DATABASE=mydb \
16
+ -e ODOO_USERNAME=admin \
17
+ -e ODOO_PASSWORD=secret \
18
+ -p 8000:8000 \
19
+ ghcr.io/godoo-dev/godoo-mcp-server:latest
20
+ ```
21
+
22
+ ## Configuration
23
+
24
+ The server resolves Odoo credentials from request headers or environment variables. Headers take precedence over environment variables.
25
+
26
+ | Credential | Header | Environment Variable |
27
+ |-----------|--------|---------------------|
28
+ | Odoo URL | `X-Odoo-Url` | `ODOO_URL` |
29
+ | Database | `X-Odoo-Database` | `ODOO_DATABASE` |
30
+ | Username | `X-Odoo-Username` | `ODOO_USERNAME` |
31
+ | Password | `X-Odoo-Password` | `ODOO_PASSWORD` |
32
+
33
+ If neither headers nor environment variables are set, the server returns a credential error.
34
+
35
+ **Server bind address:** Defaults to `0.0.0.0:8000`. Override with `HOST` and `PORT` environment variables.
36
+
37
+ > **Note:** If you override `PORT`, you must also update the Docker `HEALTHCHECK` to match the new port.
38
+
39
+ ## Security: Header Trust Model
40
+
41
+ `godoo-mcp-server` authenticates Odoo users via `X-Odoo-Username` and
42
+ `X-Odoo-Password` request headers (or `ODOO_USERNAME` / `ODOO_PASSWORD`
43
+ environment variables). **This means any caller who can reach the server
44
+ can impersonate any Odoo user by setting these headers.**
45
+
46
+ This is intentional for self-hosted / trusted-network deployments. In
47
+ production, you MUST deploy a header-stripping reverse proxy in front of
48
+ the server that enforces identity. [`godoo-proxy`](https://github.com/godoo-dev/godoo-proxy)
49
+ is the SaaS-safe gateway for this role — it implements OAuth 2.1 / SSO
50
+ and injects verified per-user credentials before forwarding to
51
+ `godoo-mcp-server`.
52
+
53
+ If you expose `godoo-mcp-server` directly on a public network without a
54
+ trusted proxy, any network peer gains full Odoo impersonation capability.
55
+
56
+ ## Running E2E tests locally
57
+
58
+ **Prerequisites:**
59
+
60
+ - Docker (for the Odoo testcontainer)
61
+ - `claude` CLI on PATH (Claude Code)
62
+ - Claude auth (API key or active OAuth session)
63
+ - Python 3.14 + `uv`
64
+
65
+ **Command:**
66
+
67
+ ```bash
68
+ uv run pytest -m e2e -s -v
69
+ ```
70
+
71
+ > **Cost warning:** Each run makes a real, billable Claude API call.
72
+ > Keep the suite small and only run when needed.
73
+
74
+ The test skips automatically when Docker or the `claude` CLI is absent — this is a clean skip, not an error.
75
+
76
+ The default `uv run pytest` excludes e2e tests automatically via the `not e2e` `addopts` filter.
@@ -0,0 +1,69 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.11,<0.12"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "godoo-mcp-server"
7
+ version = "0.1.0"
8
+ description = "Stateless MCP server for Odoo — raw primitives + skill resources"
9
+ readme = "README.md"
10
+ license = { text = "LGPL-3.0-or-later" }
11
+ requires-python = ">=3.14"
12
+ dependencies = [
13
+ "fastmcp>=3.3,<4",
14
+ "godoo-client>=0.2,<0.3",
15
+ "cachetools>=7,<8",
16
+ ]
17
+
18
+ [project.scripts]
19
+ godoo-mcp-server = "godoo_mcp_server.__main__:main"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "mypy>=2.1",
24
+ "pytest>=9",
25
+ "pytest-asyncio>=1.4",
26
+ "pre-commit>=4",
27
+ "ruff>=0.15",
28
+ "godoo-testcontainers>=0.2,<0.3",
29
+ ]
30
+
31
+ [tool.ruff]
32
+ target-version = "py314"
33
+ line-length = 100
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E", "F", "I", "UP", "B", "SIM", "TCH"]
37
+ ignore = []
38
+
39
+ [tool.mypy]
40
+ python_version = "3.14"
41
+ strict = true
42
+ warn_return_any = true
43
+ warn_unused_ignores = true
44
+
45
+ [[tool.mypy.overrides]]
46
+ module = "godoo.client.*"
47
+ ignore_missing_imports = true
48
+
49
+ [[tool.mypy.overrides]]
50
+ module = "godoo.testcontainers.*"
51
+ ignore_missing_imports = true
52
+
53
+ [tool.pytest.ini_options]
54
+ asyncio_mode = "auto"
55
+ testpaths = ["tests"]
56
+ addopts = "--tb=short -m 'not integration and not e2e'"
57
+ markers = [
58
+ "integration: marks tests as requiring a live Odoo container (deselect with -m 'not integration')",
59
+ "e2e: marks tests as requiring Docker + claude CLI + Claude auth (deselect with -m 'not e2e')",
60
+ ]
61
+
62
+ [tool.uv_build]
63
+ include = ["src/godoo_mcp_server/skills/**"]
64
+
65
+ [[tool.uv.index]]
66
+ name = "testpypi"
67
+ url = "https://test.pypi.org/simple/"
68
+ publish-url = "https://test.pypi.org/legacy/"
69
+ explicit = true
@@ -0,0 +1,4 @@
1
+ """godoo-mcp-server — Stateless MCP server for Odoo."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
@@ -0,0 +1,27 @@
1
+ """godoo-mcp-server CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from typing import Any
8
+
9
+ import uvicorn
10
+
11
+ from godoo_mcp_server import __version__
12
+ from godoo_mcp_server.server import create_app
13
+
14
+
15
+ def main() -> None:
16
+ """Start the MCP server or print version."""
17
+ if "--version" in sys.argv:
18
+ print(f"godoo-mcp-server {__version__}")
19
+ sys.exit(0)
20
+ app: Any = create_app()
21
+ host = os.environ.get("HOST", "0.0.0.0")
22
+ port = int(os.environ.get("PORT", "8000"))
23
+ uvicorn.run(app, host=host, port=port) # noqa: S104
24
+
25
+
26
+ if __name__ == "__main__":
27
+ main()
@@ -0,0 +1,166 @@
1
+ """Lazy schema-hashed catalog store with in-process LRU+TTL cache.
2
+
3
+ Cache key: (url, database, schema_hash) — NEVER includes credentials.
4
+ Schema hash: 3 write_date probes on ir.model / ir.model.fields / ir.module.module.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from typing import Any
11
+
12
+ from cachetools import TTLCache
13
+ from godoo.client.client import OdooClient
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Constants
17
+ # ---------------------------------------------------------------------------
18
+
19
+ _CATALOG_TTL = 3600 # seconds; 1 hour
20
+ _CATALOG_MAXSIZE = 32 # max distinct (url, db, hash) tuples
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Types
24
+ # ---------------------------------------------------------------------------
25
+
26
+ CacheKey = tuple[str, str, str] # (url, database, schema_hash)
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Module-level cache (sync TTLCache — safe to create at module level)
30
+ # asyncio.Lock is NEVER created here — see _get_lock() below (Pitfall 2)
31
+ # ---------------------------------------------------------------------------
32
+
33
+ _catalog_cache: TTLCache[CacheKey, dict[str, Any]] = TTLCache(
34
+ maxsize=_CATALOG_MAXSIZE, ttl=_CATALOG_TTL
35
+ )
36
+
37
+ _catalog_lock: asyncio.Lock | None = None # created lazily inside the event loop
38
+
39
+
40
+ def _get_lock() -> asyncio.Lock:
41
+ """Return the module-level asyncio.Lock, creating it lazily on first call.
42
+
43
+ CRITICAL: Must NOT be called at module import time — asyncio.Lock() must be
44
+ created inside a running event loop (Python 3.10+ requirement; strict in 3.14).
45
+ """
46
+ global _catalog_lock
47
+ if _catalog_lock is None:
48
+ _catalog_lock = asyncio.Lock()
49
+ return _catalog_lock
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Schema hash computation
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ async def compute_schema_hash(client: OdooClient) -> str | None:
58
+ """Compute a per-target schema generation token from 3 write_date probes.
59
+
60
+ Runs 3 parallel search_read calls (ir.model, ir.model.fields,
61
+ ir.module.module) and joins the most-recent write_date values with "|".
62
+
63
+ Returns None on any RPC failure — callers must handle None gracefully.
64
+ """
65
+ try:
66
+ model_rows, field_rows, module_rows = await asyncio.gather(
67
+ client.search_read(
68
+ "ir.model",
69
+ [],
70
+ fields=["write_date"],
71
+ order="write_date desc",
72
+ limit=1,
73
+ ),
74
+ client.search_read(
75
+ "ir.model.fields",
76
+ [],
77
+ fields=["write_date"],
78
+ order="write_date desc",
79
+ limit=1,
80
+ ),
81
+ client.search_read(
82
+ "ir.module.module",
83
+ [["state", "=", "installed"]],
84
+ fields=["write_date"],
85
+ order="write_date desc",
86
+ limit=1,
87
+ ),
88
+ )
89
+ model_date = (model_rows[0]["write_date"] or "") if model_rows else ""
90
+ field_date = (field_rows[0]["write_date"] or "") if field_rows else ""
91
+ module_date = (module_rows[0]["write_date"] or "") if module_rows else ""
92
+ return f"{model_date}|{field_date}|{module_date}"
93
+ except Exception: # noqa: BLE001
94
+ return None
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Snapshot builder (internal)
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ async def _build_snapshot(client: OdooClient, schema_hash: str) -> dict[str, Any]:
103
+ """Build a catalog snapshot for the given schema_hash.
104
+
105
+ Lazy: only introspects the core model set on first request to a target.
106
+ Every other model is introspected on demand (D-10).
107
+ """
108
+ _CORE_MODELS = [
109
+ "mail.message",
110
+ "res.users",
111
+ "res.partner",
112
+ "ir.attachment",
113
+ "ir.ui.menu",
114
+ ]
115
+ model_rows: list[dict[str, Any]] = await client.search_read(
116
+ "ir.model",
117
+ [["model", "in", _CORE_MODELS]],
118
+ fields=["model", "name", "info"],
119
+ )
120
+ return {"schema_hash": schema_hash, "models": model_rows}
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Public API
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ async def get_catalog_snapshot(
129
+ client: OdooClient, url: str, database: str
130
+ ) -> dict[str, Any]:
131
+ """Get or build a catalog snapshot for the given Odoo target.
132
+
133
+ The cache key is (url, database, schema_hash) — credentials are NEVER part
134
+ of the key (D-12: credential-free cache key; T-04-03-01 mitigation).
135
+
136
+ Returns a dict with keys: schema_hash, models, cached.
137
+ - cached=True → returned from TTLCache (no Odoo RPC for the snapshot)
138
+ - cached=False → freshly built and stored in cache
139
+ """
140
+ schema_hash = await compute_schema_hash(client)
141
+ if schema_hash is None:
142
+ return {"schema_hash": None, "models": [], "cached": False}
143
+
144
+ key: CacheKey = (url, database, schema_hash)
145
+ async with _get_lock():
146
+ if key in _catalog_cache:
147
+ return {**_catalog_cache[key], "cached": True}
148
+ snapshot = await _build_snapshot(client, schema_hash)
149
+ _catalog_cache[key] = snapshot
150
+ return {**snapshot, "cached": False}
151
+
152
+
153
+ def get_model_entry_from_cache(model_name: str) -> dict[str, Any] | None:
154
+ """Return the first cached model entry matching model_name, or None.
155
+
156
+ Scans all current cache entries without calling Odoo.
157
+ Returns None if the cache is cold or the model has not been introspected yet.
158
+
159
+ Intended for use by resources.py — no OdooClient, no await needed.
160
+ """
161
+ for snapshot in _catalog_cache.values():
162
+ models: list[dict[str, Any]] = snapshot.get("models", [])
163
+ for entry in models:
164
+ if entry.get("model") == model_name:
165
+ return entry
166
+ return None
@@ -0,0 +1,114 @@
1
+ """Odoo credential resolution and per-request ASGI middleware.
2
+
3
+ Resolves X-Odoo-* request headers with ODOO_* env var fallback.
4
+ Returns HTTP 401 with enumerated missing fields when any credential is absent.
5
+ Injects resolved credentials into scope["state"]["odoo_creds"] for downstream tool handlers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING
13
+
14
+ from starlette.responses import JSONResponse
15
+
16
+ if TYPE_CHECKING:
17
+ from starlette.types import ASGIApp, Receive, Scope, Send
18
+
19
+
20
+ @dataclass(repr=False)
21
+ class OdooCredentials:
22
+ """Per-request resolved Odoo credentials.
23
+
24
+ repr=False prevents accidental password leakage in logs and tracebacks.
25
+ """
26
+
27
+ url: str
28
+ database: str
29
+ username: str
30
+ password: str
31
+
32
+ def __repr__(self) -> str:
33
+ return (
34
+ f"OdooCredentials(url={self.url!r}, database={self.database!r}, "
35
+ f"username={self.username!r}, password=***)"
36
+ )
37
+
38
+
39
+ class CredentialMiddleware:
40
+ """Pure ASGI middleware: resolves Odoo credentials per-request.
41
+
42
+ Precedence: X-Odoo-* headers > ODOO_* env vars.
43
+ Returns HTTP 401 with enumerated missing fields if any credential is absent.
44
+ Stores OdooCredentials in scope["state"]["odoo_creds"] for tool handlers.
45
+
46
+ Uses pure ASGI (implements __call__ directly) to avoid contextvar propagation issues
47
+ that affect the MCP SDK's internal request context management.
48
+ """
49
+
50
+ def __init__(self, app: ASGIApp) -> None:
51
+ self._app = app
52
+
53
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
54
+ if scope["type"] != "http":
55
+ # Pass lifespan and WebSocket events through unchanged
56
+ await self._app(scope, receive, send)
57
+ return
58
+
59
+ # /healthz is unauthenticated — bypass credential check.
60
+ # Also used as Docker HEALTHCHECK in Phase 5.
61
+ if scope.get("path") == "/healthz":
62
+ await self._app(scope, receive, send)
63
+ return
64
+
65
+ raw_headers: dict[bytes, bytes] = {
66
+ name.lower(): value for name, value in scope.get("headers", [])
67
+ }
68
+
69
+ def _header(name: str) -> str | None:
70
+ val = raw_headers.get(name.encode("latin-1"), b"").decode("latin-1")
71
+ return val or None
72
+
73
+ def _env(*names: str) -> str | None:
74
+ for name in names:
75
+ val = os.environ.get(name, "")
76
+ if val:
77
+ return val
78
+ return None
79
+
80
+ url = _header("x-odoo-url") or _env("ODOO_URL")
81
+ database = _header("x-odoo-database") or _env("ODOO_DATABASE", "ODOO_DB")
82
+ username = _header("x-odoo-username") or _env("ODOO_USERNAME", "ODOO_USER")
83
+ password = _header("x-odoo-password") or _env("ODOO_PASSWORD")
84
+
85
+ missing: list[str] = []
86
+ if not url:
87
+ missing.append("X-Odoo-Url (or ODOO_URL)")
88
+ if not database:
89
+ missing.append("X-Odoo-Database (or ODOO_DATABASE / ODOO_DB)")
90
+ if not username:
91
+ missing.append("X-Odoo-Username (or ODOO_USERNAME / ODOO_USER)")
92
+ if not password:
93
+ missing.append("X-Odoo-Password (or ODOO_PASSWORD)")
94
+
95
+ if missing:
96
+ # SECURITY: enumerate missing field NAMES only — never echo supplied credential values
97
+ response = JSONResponse(
98
+ {"error": "missing_credentials", "missing": missing},
99
+ status_code=401,
100
+ )
101
+ await response(scope, receive, send)
102
+ return
103
+
104
+ # All four fields resolved — inject into scope["state"] (backs Request.state)
105
+ scope.setdefault("state", {})
106
+ # mypy: all values are non-None after the missing check above
107
+ assert url and database and username and password
108
+ scope["state"]["odoo_creds"] = OdooCredentials(
109
+ url=url,
110
+ database=database,
111
+ username=username,
112
+ password=password,
113
+ )
114
+ await self._app(scope, receive, send)
File without changes