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.
- godoo_mcp_server-0.1.0/PKG-INFO +87 -0
- godoo_mcp_server-0.1.0/README.md +76 -0
- godoo_mcp_server-0.1.0/pyproject.toml +69 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/__init__.py +4 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/__main__.py +27 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/catalog.py +166 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/credentials.py +114 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/py.typed +0 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/resources.py +167 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/server.py +53 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/skills/SKILL.md +107 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/skills/base/properties.md +81 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/skills/mail/activities.md +76 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/skills/mail/chatter.md +85 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/tools/__init__.py +0 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/tools/actions.py +95 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/tools/attachments.py +143 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/tools/discovery.py +123 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/tools/mail.py +267 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/tools/records.py +146 -0
- godoo_mcp_server-0.1.0/src/godoo_mcp_server/tools/users.py +83 -0
|
@@ -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,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
|