openom-mcp 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.
- openom_mcp-0.1.0/.gitignore +62 -0
- openom_mcp-0.1.0/PKG-INFO +112 -0
- openom_mcp-0.1.0/README.md +86 -0
- openom_mcp-0.1.0/pyproject.toml +76 -0
- openom_mcp-0.1.0/src/openom_mcp/__init__.py +2 -0
- openom_mcp-0.1.0/src/openom_mcp/apikeys.py +150 -0
- openom_mcp-0.1.0/src/openom_mcp/blobstore.py +180 -0
- openom_mcp-0.1.0/src/openom_mcp/extraction.py +30 -0
- openom_mcp-0.1.0/src/openom_mcp/fetch.py +174 -0
- openom_mcp-0.1.0/src/openom_mcp/guard.py +96 -0
- openom_mcp-0.1.0/src/openom_mcp/log.py +38 -0
- openom_mcp-0.1.0/src/openom_mcp/principal.py +23 -0
- openom_mcp-0.1.0/src/openom_mcp/ratelimit.py +113 -0
- openom_mcp-0.1.0/src/openom_mcp/redisstore.py +31 -0
- openom_mcp-0.1.0/src/openom_mcp/resolve.py +59 -0
- openom_mcp-0.1.0/src/openom_mcp/server.py +371 -0
- openom_mcp-0.1.0/src/openom_mcp/tools.py +449 -0
- openom_mcp-0.1.0/tests/test_apikeys.py +127 -0
- openom_mcp-0.1.0/tests/test_backends_env.py +67 -0
- openom_mcp-0.1.0/tests/test_blobstore.py +77 -0
- openom_mcp-0.1.0/tests/test_boundary.py +61 -0
- openom_mcp-0.1.0/tests/test_guard.py +48 -0
- openom_mcp-0.1.0/tests/test_http_config.py +49 -0
- openom_mcp-0.1.0/tests/test_http_roundtrip.py +77 -0
- openom_mcp-0.1.0/tests/test_limits.py +83 -0
- openom_mcp-0.1.0/tests/test_ratelimit.py +64 -0
- openom_mcp-0.1.0/tests/test_redisstore.py +53 -0
- openom_mcp-0.1.0/tests/test_remote.py +222 -0
- openom_mcp-0.1.0/tests/test_resolve.py +80 -0
- openom_mcp-0.1.0/tests/test_s3blobstore.py +61 -0
- openom_mcp-0.1.0/tests/test_s3blobstore_integration.py +65 -0
- openom_mcp-0.1.0/tests/test_server.py +105 -0
- openom_mcp-0.1.0/tests/test_ssrf.py +207 -0
- openom_mcp-0.1.0/tests/test_ssrf_live.py +136 -0
- openom_mcp-0.1.0/tests/test_tool_contract.py +40 -0
- openom_mcp-0.1.0/tests/test_tools.py +141 -0
- openom_mcp-0.1.0/tests/test_transport_security.py +64 -0
- openom_mcp-0.1.0/tests/test_upload.py +68 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# --- OpenOM repo hygiene (see CLAUDE.md Rule 2) ---
|
|
2
|
+
|
|
3
|
+
# Marketing mock - NEVER committed
|
|
4
|
+
OpenOM - Make your deal legible to the buy-side's AI.htm
|
|
5
|
+
OpenOM - Make your deal legible to the buy-side's AI_files/
|
|
6
|
+
|
|
7
|
+
# Planning scratch - git-ignored by rule
|
|
8
|
+
docs/superpowers/
|
|
9
|
+
.planning/
|
|
10
|
+
|
|
11
|
+
# Eval / tooling scratch
|
|
12
|
+
.deepeval/
|
|
13
|
+
|
|
14
|
+
# Real OM corpus - private test fixtures, never committed (confidential)
|
|
15
|
+
OMs/
|
|
16
|
+
|
|
17
|
+
# --- Python (/core, /cli, /mcp) ---
|
|
18
|
+
__pycache__/
|
|
19
|
+
*.py[cod]
|
|
20
|
+
*.egg-info/
|
|
21
|
+
.eggs/
|
|
22
|
+
build/
|
|
23
|
+
dist/
|
|
24
|
+
.venv/
|
|
25
|
+
venv/
|
|
26
|
+
.env
|
|
27
|
+
.mypy_cache/
|
|
28
|
+
.ruff_cache/
|
|
29
|
+
.pytest_cache/
|
|
30
|
+
.coverage
|
|
31
|
+
htmlcov/
|
|
32
|
+
|
|
33
|
+
# --- Node / TS (/js, /extension) ---
|
|
34
|
+
node_modules/
|
|
35
|
+
*.tsbuildinfo
|
|
36
|
+
.npm/
|
|
37
|
+
extension/src/generated/
|
|
38
|
+
extension/test-results/
|
|
39
|
+
extension/playwright-report/
|
|
40
|
+
|
|
41
|
+
# --- OS / editor ---
|
|
42
|
+
.DS_Store
|
|
43
|
+
Thumbs.db
|
|
44
|
+
.idea/
|
|
45
|
+
.vscode/
|
|
46
|
+
coverage/
|
|
47
|
+
extension/*.zip
|
|
48
|
+
|
|
49
|
+
js/test-results/
|
|
50
|
+
|
|
51
|
+
# marketing mock (Rule 2 - never commit)
|
|
52
|
+
*.htm
|
|
53
|
+
*_files/
|
|
54
|
+
.playwright-mcp/
|
|
55
|
+
om-normative-spec-draft-v0.1.md
|
|
56
|
+
om-standard-handoff-v4-updated.md
|
|
57
|
+
recon.md
|
|
58
|
+
openOM-decision-memo.md
|
|
59
|
+
|
|
60
|
+
# audit/backlog scratch is temp - lives in .planning/ (see Rule 2)
|
|
61
|
+
/BACKLOG.md
|
|
62
|
+
/*-audit*.md
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openom-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: openOM MCP server - deterministic tool surface over openom-core. Zero inference, ever.
|
|
5
|
+
Project-URL: Homepage, https://openom.app
|
|
6
|
+
Project-URL: Documentation, https://openom.app/docs/grounding-ai
|
|
7
|
+
Project-URL: Repository, https://github.com/Vervelio-Labs/OpenOM
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: ai-agents,commercial-real-estate,cre,json-ld,mcp,model-context-protocol,offering-memorandum,open-standard,openom,proptech
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Requires-Dist: mcp<3,>=2.0
|
|
13
|
+
Requires-Dist: openom-core[render]<0.2,>=0.1
|
|
14
|
+
Requires-Dist: uvicorn>=0.30
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: boto3>=1.34; extra == 'dev'
|
|
17
|
+
Requires-Dist: fakeredis>=2.20; extra == 'dev'
|
|
18
|
+
Requires-Dist: moto[s3]>=5; extra == 'dev'
|
|
19
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
23
|
+
Provides-Extra: r2
|
|
24
|
+
Requires-Dist: boto3>=1.34; extra == 'r2'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# openom-mcp
|
|
28
|
+
|
|
29
|
+
A thin, **deterministic** [FastMCP](https://github.com/jlowin/fastmcp) server over
|
|
30
|
+
[`openom-core`](../core) - six data verbs, zero inference:
|
|
31
|
+
`om_inspect · om_extract_text · om_extract_images · om_read · om_validate · om_embed`, plus one
|
|
32
|
+
hosted-transport helper `om_request_upload` (reserves a presigned upload target; HTTP transport only).
|
|
33
|
+
So `tools/list` reports **seven** tools.
|
|
34
|
+
Two transports: **stdio** (`om-mcp`) and **hosted Streamable HTTP** (`om-mcp-http`, SSRF-guarded,
|
|
35
|
+
rate-limited, untrusted-parse-isolated).
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pip install openom-mcp # depends on openom-core (also from PyPI)
|
|
39
|
+
# from a clone (openom-core is not yet on PyPI): install core first, then mcp:
|
|
40
|
+
pip install -e ./core && pip install -e "./mcp[dev]"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Connect it to an MCP client (stdio)
|
|
44
|
+
|
|
45
|
+
Add to your client's MCP config (e.g. Claude Desktop `claude_desktop_config.json`, or any
|
|
46
|
+
`mcp.json`):
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"mcpServers": {
|
|
51
|
+
"openom": { "command": "om-mcp" }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Hosted HTTP
|
|
57
|
+
|
|
58
|
+
`om-mcp-http` serves Streamable HTTP at the **`/mcp`** path (e.g. `http://127.0.0.1:8080/mcp`). By
|
|
59
|
+
default it binds **loopback** (`127.0.0.1:8080`) - safe out of the box, not a world-open server. The
|
|
60
|
+
free **public grounding endpoint** at `https://mcp.openom.app/mcp` is a serverless Cloudflare Worker
|
|
61
|
+
(`/mcp-worker`) exposing the read-side `om_read` + `om_validate` via the byte-parity `/js` core; run
|
|
62
|
+
`om-mcp-http` yourself for the full six-tool surface (extract/embed/inspect). Two-tier validation:
|
|
63
|
+
schema errors block, consistency warnings never do; market truth is out of scope.
|
|
64
|
+
|
|
65
|
+
**Tool inputs.** The PDF-taking tools (`om_inspect`/`om_read`/`om_extract_*`/`om_embed`) accept a
|
|
66
|
+
`pdf` object that is exactly one of: `{"path": "..."}` (stdio, a local file), `{"url": "https://..."}`
|
|
67
|
+
(HTTP transport, fetched under the SSRF rules), or `{"blobId": "..."}` (HTTP, a prior
|
|
68
|
+
`om_request_upload`). `om_validate` takes a `payload` object.
|
|
69
|
+
|
|
70
|
+
**Stability.** The seven-tool surface + result shapes are versioned with the spec (`specVersion`
|
|
71
|
+
`0.1`); the `om_read`/`om_validate` shapes match the public Worker ([Ma9]). Breaking tool-surface
|
|
72
|
+
changes ship under a new spec minor. See the repo `spec/CHANGELOG.md`.
|
|
73
|
+
|
|
74
|
+
### Configuration (env)
|
|
75
|
+
|
|
76
|
+
Every knob is an environment variable; defaults are safe for local use:
|
|
77
|
+
|
|
78
|
+
| Variable | Default | Meaning |
|
|
79
|
+
| --- | --- | --- |
|
|
80
|
+
| `OPENOM_MCP_HOST` | `127.0.0.1` | Bind address. Set `0.0.0.0` to expose publicly. |
|
|
81
|
+
| `OPENOM_MCP_PORT` | `8080` | Bind port. |
|
|
82
|
+
| `OPENOM_MCP_DNS_REBINDING` | auto | Host/Origin DNS-rebinding defense. **Auto-ON when bound to a non-loopback host.** |
|
|
83
|
+
| `OPENOM_MCP_ALLOWED_HOSTS` | *(empty)* | Comma-separated Host allowlist (set this when binding publicly). |
|
|
84
|
+
| `OPENOM_MCP_ALLOWED_ORIGINS` | *(empty)* | Comma-separated Origin allowlist. |
|
|
85
|
+
| `OPENOM_MCP_RATE_LIMIT` / `_WINDOW` | `120` / `60` | Per-principal request cap per window (seconds). |
|
|
86
|
+
| `OPENOM_MCP_MAX_FETCH_BYTES` | `209715200` | Cap on fetched PDF size (SSRF/DoS guard). |
|
|
87
|
+
| `OPENOM_MCP_MAX_PAGES` | *(unset)* | Per-call page ceiling for extraction. |
|
|
88
|
+
| `OPENOM_MCP_LOG` | - | Log level. |
|
|
89
|
+
|
|
90
|
+
When you bind publicly (`0.0.0.0`) always set `OPENOM_MCP_ALLOWED_HOSTS`/`_ORIGINS` - the server logs
|
|
91
|
+
a warning if you don't.
|
|
92
|
+
|
|
93
|
+
**Production backends (env-selectable):**
|
|
94
|
+
|
|
95
|
+
| Variable | Default | Meaning |
|
|
96
|
+
| --- | --- | --- |
|
|
97
|
+
| `OPENOM_MCP_BLOB_BACKEND` | `local` | `r2` uses Cloudflare R2/S3 (needs `OPENOM_R2_BUCKET`, `OPENOM_R2_ENDPOINT`, `OPENOM_R2_ACCESS_KEY`, `OPENOM_R2_SECRET_KEY`; `boto3` extra). |
|
|
98
|
+
| `OPENOM_MCP_LIMITER` | `memory` | `redis` uses a shared Redis so multiple replicas enforce one global limit (needs `OPENOM_REDIS_URL`; a `redis`-py-compatible client). |
|
|
99
|
+
|
|
100
|
+
(Both `boto3` and `redis` are imported lazily - neither is a hard dependency.) For anything further,
|
|
101
|
+
`build_http_app(...)` accepts `blob_store` / `rate_limiter` injections directly.
|
|
102
|
+
|
|
103
|
+
## Grounding an AI agent
|
|
104
|
+
|
|
105
|
+
Treat an openOM payload as the broker's **asserted opinion**, not fact: attribute every figure to
|
|
106
|
+
`assertedBy` as of `assertedDate`, don't use a payload whose `verification.hashValid` isn't true,
|
|
107
|
+
and never present an OM figure as verified truth (an OM is an advertisement / opinion of value). The
|
|
108
|
+
full guide - MCP config, tool usage, and a system-prompt snippet - is
|
|
109
|
+
**[Grounding AI agents in openOM](../site/openom/docs/grounding-ai.html)** (served at
|
|
110
|
+
`…/openom/docs/grounding-ai.html`).
|
|
111
|
+
|
|
112
|
+
Tests: `pytest mcp -q`.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# openom-mcp
|
|
2
|
+
|
|
3
|
+
A thin, **deterministic** [FastMCP](https://github.com/jlowin/fastmcp) server over
|
|
4
|
+
[`openom-core`](../core) - six data verbs, zero inference:
|
|
5
|
+
`om_inspect · om_extract_text · om_extract_images · om_read · om_validate · om_embed`, plus one
|
|
6
|
+
hosted-transport helper `om_request_upload` (reserves a presigned upload target; HTTP transport only).
|
|
7
|
+
So `tools/list` reports **seven** tools.
|
|
8
|
+
Two transports: **stdio** (`om-mcp`) and **hosted Streamable HTTP** (`om-mcp-http`, SSRF-guarded,
|
|
9
|
+
rate-limited, untrusted-parse-isolated).
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pip install openom-mcp # depends on openom-core (also from PyPI)
|
|
13
|
+
# from a clone (openom-core is not yet on PyPI): install core first, then mcp:
|
|
14
|
+
pip install -e ./core && pip install -e "./mcp[dev]"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Connect it to an MCP client (stdio)
|
|
18
|
+
|
|
19
|
+
Add to your client's MCP config (e.g. Claude Desktop `claude_desktop_config.json`, or any
|
|
20
|
+
`mcp.json`):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"openom": { "command": "om-mcp" }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Hosted HTTP
|
|
31
|
+
|
|
32
|
+
`om-mcp-http` serves Streamable HTTP at the **`/mcp`** path (e.g. `http://127.0.0.1:8080/mcp`). By
|
|
33
|
+
default it binds **loopback** (`127.0.0.1:8080`) - safe out of the box, not a world-open server. The
|
|
34
|
+
free **public grounding endpoint** at `https://mcp.openom.app/mcp` is a serverless Cloudflare Worker
|
|
35
|
+
(`/mcp-worker`) exposing the read-side `om_read` + `om_validate` via the byte-parity `/js` core; run
|
|
36
|
+
`om-mcp-http` yourself for the full six-tool surface (extract/embed/inspect). Two-tier validation:
|
|
37
|
+
schema errors block, consistency warnings never do; market truth is out of scope.
|
|
38
|
+
|
|
39
|
+
**Tool inputs.** The PDF-taking tools (`om_inspect`/`om_read`/`om_extract_*`/`om_embed`) accept a
|
|
40
|
+
`pdf` object that is exactly one of: `{"path": "..."}` (stdio, a local file), `{"url": "https://..."}`
|
|
41
|
+
(HTTP transport, fetched under the SSRF rules), or `{"blobId": "..."}` (HTTP, a prior
|
|
42
|
+
`om_request_upload`). `om_validate` takes a `payload` object.
|
|
43
|
+
|
|
44
|
+
**Stability.** The seven-tool surface + result shapes are versioned with the spec (`specVersion`
|
|
45
|
+
`0.1`); the `om_read`/`om_validate` shapes match the public Worker ([Ma9]). Breaking tool-surface
|
|
46
|
+
changes ship under a new spec minor. See the repo `spec/CHANGELOG.md`.
|
|
47
|
+
|
|
48
|
+
### Configuration (env)
|
|
49
|
+
|
|
50
|
+
Every knob is an environment variable; defaults are safe for local use:
|
|
51
|
+
|
|
52
|
+
| Variable | Default | Meaning |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| `OPENOM_MCP_HOST` | `127.0.0.1` | Bind address. Set `0.0.0.0` to expose publicly. |
|
|
55
|
+
| `OPENOM_MCP_PORT` | `8080` | Bind port. |
|
|
56
|
+
| `OPENOM_MCP_DNS_REBINDING` | auto | Host/Origin DNS-rebinding defense. **Auto-ON when bound to a non-loopback host.** |
|
|
57
|
+
| `OPENOM_MCP_ALLOWED_HOSTS` | *(empty)* | Comma-separated Host allowlist (set this when binding publicly). |
|
|
58
|
+
| `OPENOM_MCP_ALLOWED_ORIGINS` | *(empty)* | Comma-separated Origin allowlist. |
|
|
59
|
+
| `OPENOM_MCP_RATE_LIMIT` / `_WINDOW` | `120` / `60` | Per-principal request cap per window (seconds). |
|
|
60
|
+
| `OPENOM_MCP_MAX_FETCH_BYTES` | `209715200` | Cap on fetched PDF size (SSRF/DoS guard). |
|
|
61
|
+
| `OPENOM_MCP_MAX_PAGES` | *(unset)* | Per-call page ceiling for extraction. |
|
|
62
|
+
| `OPENOM_MCP_LOG` | - | Log level. |
|
|
63
|
+
|
|
64
|
+
When you bind publicly (`0.0.0.0`) always set `OPENOM_MCP_ALLOWED_HOSTS`/`_ORIGINS` - the server logs
|
|
65
|
+
a warning if you don't.
|
|
66
|
+
|
|
67
|
+
**Production backends (env-selectable):**
|
|
68
|
+
|
|
69
|
+
| Variable | Default | Meaning |
|
|
70
|
+
| --- | --- | --- |
|
|
71
|
+
| `OPENOM_MCP_BLOB_BACKEND` | `local` | `r2` uses Cloudflare R2/S3 (needs `OPENOM_R2_BUCKET`, `OPENOM_R2_ENDPOINT`, `OPENOM_R2_ACCESS_KEY`, `OPENOM_R2_SECRET_KEY`; `boto3` extra). |
|
|
72
|
+
| `OPENOM_MCP_LIMITER` | `memory` | `redis` uses a shared Redis so multiple replicas enforce one global limit (needs `OPENOM_REDIS_URL`; a `redis`-py-compatible client). |
|
|
73
|
+
|
|
74
|
+
(Both `boto3` and `redis` are imported lazily - neither is a hard dependency.) For anything further,
|
|
75
|
+
`build_http_app(...)` accepts `blob_store` / `rate_limiter` injections directly.
|
|
76
|
+
|
|
77
|
+
## Grounding an AI agent
|
|
78
|
+
|
|
79
|
+
Treat an openOM payload as the broker's **asserted opinion**, not fact: attribute every figure to
|
|
80
|
+
`assertedBy` as of `assertedDate`, don't use a payload whose `verification.hashValid` isn't true,
|
|
81
|
+
and never present an OM figure as verified truth (an OM is an advertisement / opinion of value). The
|
|
82
|
+
full guide - MCP config, tool usage, and a system-prompt snippet - is
|
|
83
|
+
**[Grounding AI agents in openOM](../site/openom/docs/grounding-ai.html)** (served at
|
|
84
|
+
`…/openom/docs/grounding-ai.html`).
|
|
85
|
+
|
|
86
|
+
Tests: `pytest mcp -q`.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "openom-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "openOM MCP server - deterministic tool surface over openom-core. Zero inference, ever."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = [
|
|
13
|
+
"openom", "mcp", "model-context-protocol", "offering-memorandum", "commercial-real-estate",
|
|
14
|
+
"cre", "proptech", "json-ld", "open-standard", "ai-agents",
|
|
15
|
+
]
|
|
16
|
+
# openom-core pinned to the same 0.x spec line (SemVer: a 0.2 core may change the payload contract).
|
|
17
|
+
dependencies = ["openom-core[render]>=0.1,<0.2", "mcp>=2.0,<3", "httpx>=0.27", "uvicorn>=0.30"]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=8", "pytest-cov>=5", "mypy>=1.10", "ruff>=0.5", "boto3>=1.34", "moto[s3]>=5",
|
|
22
|
+
"fakeredis>=2.20", # exercise RedisCounterStore without a live server (#51/#52)
|
|
23
|
+
]
|
|
24
|
+
r2 = ["boto3>=1.34"] # S3BlobStore (R2) adapter - runtime extra for a real deployment
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://openom.app"
|
|
28
|
+
Documentation = "https://openom.app/docs/grounding-ai"
|
|
29
|
+
Repository = "https://github.com/Vervelio-Labs/OpenOM"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
om-mcp = "openom_mcp.server:main"
|
|
33
|
+
om-mcp-http = "openom_mcp.server:main_http"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/openom_mcp"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
line-length = 100
|
|
40
|
+
src = ["src", "tests"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = ["E", "F", "I", "B", "UP", "W"]
|
|
44
|
+
|
|
45
|
+
[tool.mypy]
|
|
46
|
+
python_version = "3.11"
|
|
47
|
+
strict = true
|
|
48
|
+
files = ["src"]
|
|
49
|
+
# The MCP server SDK exposes an untyped @tool() decorator; the tool fns themselves are fully typed.
|
|
50
|
+
disallow_untyped_decorators = false
|
|
51
|
+
# boto3/botocore ship no py.typed; when installed (CI) mypy emits import-untyped attributed to the
|
|
52
|
+
# IMPORTING file (openom_mcp.blobstore), so it must be disabled globally, not on the boto3 override.
|
|
53
|
+
disable_error_code = ["import-untyped"]
|
|
54
|
+
|
|
55
|
+
[[tool.mypy.overrides]]
|
|
56
|
+
# Third-party SDK + OPTIONAL runtime deps (boto3/uvicorn/redis are lazy extras, absent in the base
|
|
57
|
+
# env). ignore_missing_imports keeps mypy green whether or not they're installed - no per-line,
|
|
58
|
+
# env-fragile `type: ignore` comments needed.
|
|
59
|
+
module = [
|
|
60
|
+
"mcp", "mcp.*", "pymupdf", "pymupdf.*",
|
|
61
|
+
"boto3", "botocore", "botocore.*", "uvicorn", "redis",
|
|
62
|
+
]
|
|
63
|
+
ignore_missing_imports = true
|
|
64
|
+
follow_imports = "skip"
|
|
65
|
+
|
|
66
|
+
[tool.pytest.ini_options]
|
|
67
|
+
testpaths = ["tests"]
|
|
68
|
+
addopts = "-ra"
|
|
69
|
+
|
|
70
|
+
[tool.coverage.run]
|
|
71
|
+
source = ["openom_mcp"]
|
|
72
|
+
branch = true
|
|
73
|
+
|
|
74
|
+
[tool.coverage.report]
|
|
75
|
+
show_missing = true
|
|
76
|
+
exclude_lines = ["pragma: no cover", "if __name__"]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""API-key lifecycle for the hosted transport (#52): issue/verify/rotate/revoke + per-key quota.
|
|
3
|
+
|
|
4
|
+
Deterministic logic over a pluggable ``KeyStore`` (+ the ``CounterStore`` seam from ratelimit.py for
|
|
5
|
+
quota accounting). The hosted deploy binds a real KV/DB + Redis; ``InMemoryKeyStore`` is the
|
|
6
|
+
test/self-host impl. No inference - this is the paid instance's access layer over the
|
|
7
|
+
same deterministic engine.
|
|
8
|
+
|
|
9
|
+
Security posture: the plaintext key (``omk_<token>``) is shown EXACTLY ONCE, at issue; only its
|
|
10
|
+
SHA-256 hash is ever stored or logged. Verification is hash-lookup + status check; rotation issues a
|
|
11
|
+
new key and revokes the old; revocation is immediate. Quota is a per-key volume cap over a window,
|
|
12
|
+
distinct from the short-window burst rate limit.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import secrets
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass, replace
|
|
22
|
+
from typing import Literal, Protocol
|
|
23
|
+
|
|
24
|
+
from .ratelimit import CounterStore
|
|
25
|
+
from .tools import ToolError
|
|
26
|
+
|
|
27
|
+
KEY_PREFIX = "omk_"
|
|
28
|
+
KeyStatus = Literal["active", "revoked"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def hash_key(plaintext: str) -> str:
|
|
32
|
+
"""The stored/loggable identifier for a key: its SHA-256 hex. Never store the plaintext."""
|
|
33
|
+
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ApiKeyRecord:
|
|
38
|
+
key_id: str # public, non-secret handle (safe to show in a dashboard/logs)
|
|
39
|
+
key_hash: str # sha256(plaintext); the lookup key
|
|
40
|
+
owner: str # principal that owns the key
|
|
41
|
+
created: float
|
|
42
|
+
status: KeyStatus = "active"
|
|
43
|
+
quota_limit: int = 0 # max calls per quota window; 0 = unlimited
|
|
44
|
+
quota_window_seconds: int = 86_400 # default: per day
|
|
45
|
+
revoked_at: float | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class KeyStore(Protocol):
|
|
49
|
+
"""Persistence seam. The hosted deploy backs this with a KV/DB; tests use InMemoryKeyStore."""
|
|
50
|
+
|
|
51
|
+
def get_by_hash(self, key_hash: str) -> ApiKeyRecord | None: ...
|
|
52
|
+
def get_by_id(self, key_id: str) -> ApiKeyRecord | None: ...
|
|
53
|
+
def put(self, record: ApiKeyRecord) -> None: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class InMemoryKeyStore:
|
|
57
|
+
"""Single-process KeyStore for tests / self-host."""
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
self._by_hash: dict[str, ApiKeyRecord] = {}
|
|
61
|
+
self._by_id: dict[str, ApiKeyRecord] = {}
|
|
62
|
+
|
|
63
|
+
def get_by_hash(self, key_hash: str) -> ApiKeyRecord | None:
|
|
64
|
+
return self._by_hash.get(key_hash)
|
|
65
|
+
|
|
66
|
+
def get_by_id(self, key_id: str) -> ApiKeyRecord | None:
|
|
67
|
+
return self._by_id.get(key_id)
|
|
68
|
+
|
|
69
|
+
def put(self, record: ApiKeyRecord) -> None:
|
|
70
|
+
self._by_hash[record.key_hash] = record
|
|
71
|
+
self._by_id[record.key_id] = record
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ApiKeyManager:
|
|
75
|
+
"""Issue / verify / rotate / revoke API keys and enforce per-key quota."""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
store: KeyStore,
|
|
80
|
+
counters: CounterStore,
|
|
81
|
+
*,
|
|
82
|
+
now: Callable[[], float] = time.time,
|
|
83
|
+
rng: Callable[[], str] = lambda: secrets.token_urlsafe(24),
|
|
84
|
+
) -> None:
|
|
85
|
+
self.store = store
|
|
86
|
+
self.counters = counters
|
|
87
|
+
self.now = now
|
|
88
|
+
self._rng = rng # injectable for deterministic tests
|
|
89
|
+
|
|
90
|
+
def issue(
|
|
91
|
+
self, owner: str, *, quota_limit: int = 0, quota_window_seconds: int = 86_400
|
|
92
|
+
) -> tuple[str, ApiKeyRecord]:
|
|
93
|
+
"""Mint a new key for ``owner``. Returns (plaintext_shown_once, record). Store keeps only
|
|
94
|
+
the hash - the plaintext is unrecoverable after this call."""
|
|
95
|
+
plaintext = KEY_PREFIX + self._rng()
|
|
96
|
+
record = ApiKeyRecord(
|
|
97
|
+
key_id="k_" + secrets.token_hex(8),
|
|
98
|
+
key_hash=hash_key(plaintext),
|
|
99
|
+
owner=owner,
|
|
100
|
+
created=self.now(),
|
|
101
|
+
quota_limit=quota_limit,
|
|
102
|
+
quota_window_seconds=quota_window_seconds,
|
|
103
|
+
)
|
|
104
|
+
self.store.put(record)
|
|
105
|
+
return plaintext, record
|
|
106
|
+
|
|
107
|
+
def verify(self, plaintext: str) -> ApiKeyRecord | None:
|
|
108
|
+
"""Return the ACTIVE record for a plaintext key, or None (unknown / revoked / malformed)."""
|
|
109
|
+
if not plaintext.startswith(KEY_PREFIX):
|
|
110
|
+
return None
|
|
111
|
+
record = self.store.get_by_hash(hash_key(plaintext))
|
|
112
|
+
if record is None or record.status != "active":
|
|
113
|
+
return None
|
|
114
|
+
return record
|
|
115
|
+
|
|
116
|
+
def revoke(self, key_id: str) -> bool:
|
|
117
|
+
"""Immediately deactivate a key. Returns True if a key was revoked, False if unknown."""
|
|
118
|
+
record = self.store.get_by_id(key_id)
|
|
119
|
+
if record is None or record.status == "revoked":
|
|
120
|
+
return False
|
|
121
|
+
self.store.put(replace(record, status="revoked", revoked_at=self.now()))
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
def rotate(self, key_id: str) -> tuple[str, ApiKeyRecord] | None:
|
|
125
|
+
"""Issue a replacement key for the same owner (same quota policy) and revoke the old one.
|
|
126
|
+
Returns the new (plaintext, record), or None if ``key_id`` is unknown."""
|
|
127
|
+
old = self.store.get_by_id(key_id)
|
|
128
|
+
if old is None:
|
|
129
|
+
return None
|
|
130
|
+
new = self.issue(
|
|
131
|
+
old.owner,
|
|
132
|
+
quota_limit=old.quota_limit,
|
|
133
|
+
quota_window_seconds=old.quota_window_seconds,
|
|
134
|
+
)
|
|
135
|
+
self.revoke(key_id)
|
|
136
|
+
return new
|
|
137
|
+
|
|
138
|
+
def check_quota(self, record: ApiKeyRecord) -> None:
|
|
139
|
+
"""Count one call against the key's quota window; raise OM-IO-014 when the cap is exceeded.
|
|
140
|
+
A key with ``quota_limit == 0`` is unlimited (only the burst rate limit applies)."""
|
|
141
|
+
if record.quota_limit <= 0:
|
|
142
|
+
return
|
|
143
|
+
window = record.quota_window_seconds
|
|
144
|
+
idx = int(self.now() // window)
|
|
145
|
+
count = self.counters.incr(f"quota:{record.key_id}:{idx}", window)
|
|
146
|
+
if count > record.quota_limit:
|
|
147
|
+
retry = max(1, int(window - (self.now() % window)))
|
|
148
|
+
raise ToolError(
|
|
149
|
+
"OM-IO-014", "quota exceeded", retryable=True, retry_after=retry
|
|
150
|
+
)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Blob storage for the hosted transport (spec §K, [OM-SEC-006/013], Q4 retention).
|
|
3
|
+
|
|
4
|
+
Uploaded/produced PDFs live as short-lived blobs (≤24h TTL + delete-on-completion). ``BlobStore``
|
|
5
|
+
is the interface; ``LocalBlobStore`` (tmpdir + an injected clock) proves the TTL/delete/authz
|
|
6
|
+
semantics offline for the gate. ``S3BlobStore`` (the R2 adapter, Task 8) implements the same
|
|
7
|
+
interface for production. Zero inference, zero network in this module's tested path.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .tools import ToolError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def new_blob_id() -> str:
|
|
24
|
+
"""An unguessable blob id (≥128 bits of entropy, URL-safe) ([OM-SEC-013])."""
|
|
25
|
+
return secrets.token_urlsafe(32)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _iso(epoch: float) -> str:
|
|
29
|
+
return datetime.fromtimestamp(epoch, tz=UTC).isoformat()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BlobStore(ABC):
|
|
33
|
+
"""Single-tenant, TTL-bounded blob storage keyed by an unguessable ``blobId``."""
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def create_upload(self, principal: str) -> dict[str, str]:
|
|
37
|
+
"""Reserve a blob and return ``{blobId, presignedPut, expiresAt}`` for a client upload."""
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def get(self, blob_id: str, principal: str) -> bytes:
|
|
41
|
+
"""Return the blob bytes, or raise OM-IO-006 (missing/expired) / OM-IO-007 (authz)."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def put_result(self, data: bytes, principal: str) -> dict[str, str]:
|
|
45
|
+
"""Store produced bytes; return ``{blobId, presignedGet, expiresAt}``."""
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def delete(self, blob_id: str) -> None:
|
|
49
|
+
"""Remove a blob (idempotent)."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LocalBlobStore(BlobStore):
|
|
53
|
+
"""Filesystem-backed store for tests/self-host. Presigned URLs are loopback stubs."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self, root: Path, *, ttl_seconds: int = 86400, now: Callable[[], float] = time.time
|
|
57
|
+
) -> None:
|
|
58
|
+
self.root = Path(root)
|
|
59
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
self.ttl = ttl_seconds
|
|
61
|
+
self.now = now
|
|
62
|
+
self._meta: dict[str, dict[str, float | str]] = {}
|
|
63
|
+
|
|
64
|
+
def _record(self, blob_id: str, principal: str) -> str:
|
|
65
|
+
created = self.now()
|
|
66
|
+
self._meta[blob_id] = {"owner": principal, "created": created}
|
|
67
|
+
return _iso(created + self.ttl)
|
|
68
|
+
|
|
69
|
+
def create_upload(self, principal: str) -> dict[str, str]:
|
|
70
|
+
blob_id = new_blob_id()
|
|
71
|
+
expires = self._record(blob_id, principal)
|
|
72
|
+
return {"blobId": blob_id, "presignedPut": f"local://put/{blob_id}", "expiresAt": expires}
|
|
73
|
+
|
|
74
|
+
def put_result(self, data: bytes, principal: str) -> dict[str, str]:
|
|
75
|
+
blob_id = new_blob_id()
|
|
76
|
+
expires = self._record(blob_id, principal)
|
|
77
|
+
(self.root / blob_id).write_bytes(data)
|
|
78
|
+
return {"blobId": blob_id, "presignedGet": f"local://get/{blob_id}", "expiresAt": expires}
|
|
79
|
+
|
|
80
|
+
def get(self, blob_id: str, principal: str) -> bytes:
|
|
81
|
+
meta = self._meta.get(blob_id)
|
|
82
|
+
path = self.root / blob_id
|
|
83
|
+
if meta is None or not path.exists():
|
|
84
|
+
raise ToolError("OM-IO-006", "blobId not found or expired")
|
|
85
|
+
if self.now() - float(meta["created"]) > self.ttl: # TTL backstop
|
|
86
|
+
self.delete(blob_id)
|
|
87
|
+
raise ToolError("OM-IO-006", "blobId not found or expired")
|
|
88
|
+
if meta["owner"] != principal: # existence-then-authz (anti-IDOR, OM-SEC-013)
|
|
89
|
+
raise ToolError("OM-IO-007", "blobId not authorized for this principal")
|
|
90
|
+
return path.read_bytes()
|
|
91
|
+
|
|
92
|
+
def delete(self, blob_id: str) -> None:
|
|
93
|
+
self._meta.pop(blob_id, None)
|
|
94
|
+
(self.root / blob_id).unlink(missing_ok=True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class S3BlobStore(BlobStore):
|
|
98
|
+
"""R2/S3 adapter with **server-bound ownership** (#50). Ownership is recorded by a companion
|
|
99
|
+
``_owners/<blobId>`` object that ONLY this server writes - never derived from client-supplied
|
|
100
|
+
object metadata - so a presigned PUT (which can write only the data object) cannot forge or
|
|
101
|
+
omit ownership. ``get`` reads the server-written owner for authz; TTL is enforced both by a
|
|
102
|
+
≤24h bucket lifecycle rule and from the object's ``LastModified``. boto3 is an optional extra;
|
|
103
|
+
inject ``client`` for tests. authz/TTL semantics match ``LocalBlobStore``.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self, *, bucket: str, endpoint_url: str = "", access_key: str = "", secret_key: str = "",
|
|
108
|
+
ttl_seconds: int = 86400, client: Any = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
self.bucket = bucket
|
|
111
|
+
self.ttl = ttl_seconds
|
|
112
|
+
self.s3 = client if client is not None else self._make_client(
|
|
113
|
+
endpoint_url, access_key, secret_key
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _make_client( # pragma: no cover - real boto3 client; moto tests inject a client
|
|
118
|
+
endpoint_url: str, access_key: str, secret_key: str
|
|
119
|
+
) -> Any:
|
|
120
|
+
import boto3
|
|
121
|
+
|
|
122
|
+
return boto3.client(
|
|
123
|
+
"s3", endpoint_url=endpoint_url,
|
|
124
|
+
aws_access_key_id=access_key, aws_secret_access_key=secret_key,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _expires(self) -> str:
|
|
128
|
+
return _iso(time.time() + self.ttl)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _owner_key(blob_id: str) -> str:
|
|
132
|
+
return f"_owners/{blob_id}"
|
|
133
|
+
|
|
134
|
+
def _bind_owner(self, blob_id: str, principal: str) -> None:
|
|
135
|
+
# Server-written, not client-controlled: the presigned PUT covers only the data key.
|
|
136
|
+
self.s3.put_object(
|
|
137
|
+
Bucket=self.bucket, Key=self._owner_key(blob_id), Body=principal.encode()
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def create_upload(self, principal: str) -> dict[str, str]:
|
|
141
|
+
blob_id = new_blob_id()
|
|
142
|
+
self._bind_owner(blob_id, principal)
|
|
143
|
+
url = self.s3.generate_presigned_url(
|
|
144
|
+
"put_object", Params={"Bucket": self.bucket, "Key": blob_id},
|
|
145
|
+
ExpiresIn=min(self.ttl, 3600),
|
|
146
|
+
)
|
|
147
|
+
return {"blobId": blob_id, "presignedPut": url, "expiresAt": self._expires()}
|
|
148
|
+
|
|
149
|
+
def put_result(self, data: bytes, principal: str) -> dict[str, str]:
|
|
150
|
+
blob_id = new_blob_id()
|
|
151
|
+
self._bind_owner(blob_id, principal)
|
|
152
|
+
self.s3.put_object(Bucket=self.bucket, Key=blob_id, Body=data)
|
|
153
|
+
url = self.s3.generate_presigned_url(
|
|
154
|
+
"get_object", Params={"Bucket": self.bucket, "Key": blob_id},
|
|
155
|
+
ExpiresIn=min(self.ttl, 3600),
|
|
156
|
+
)
|
|
157
|
+
return {"blobId": blob_id, "presignedGet": url, "expiresAt": self._expires()}
|
|
158
|
+
|
|
159
|
+
def get(self, blob_id: str, principal: str) -> bytes:
|
|
160
|
+
from botocore.exceptions import ClientError
|
|
161
|
+
|
|
162
|
+
try: # authz first, from the server-written owner record (anti-IDOR, anti-forge)
|
|
163
|
+
owner = self.s3.get_object(Bucket=self.bucket, Key=self._owner_key(blob_id))
|
|
164
|
+
except ClientError as exc:
|
|
165
|
+
raise ToolError("OM-IO-006", "blobId not found or expired") from exc
|
|
166
|
+
if owner["Body"].read().decode() != principal:
|
|
167
|
+
raise ToolError("OM-IO-007", "blobId not authorized for this principal")
|
|
168
|
+
try:
|
|
169
|
+
obj = self.s3.get_object(Bucket=self.bucket, Key=blob_id)
|
|
170
|
+
except ClientError as exc:
|
|
171
|
+
raise ToolError("OM-IO-006", "blobId not found or expired") from exc
|
|
172
|
+
if time.time() - obj["LastModified"].timestamp() > self.ttl:
|
|
173
|
+
self.delete(blob_id)
|
|
174
|
+
raise ToolError("OM-IO-006", "blobId not found or expired")
|
|
175
|
+
body: bytes = obj["Body"].read()
|
|
176
|
+
return body
|
|
177
|
+
|
|
178
|
+
def delete(self, blob_id: str) -> None:
|
|
179
|
+
self.s3.delete_object(Bucket=self.bucket, Key=blob_id)
|
|
180
|
+
self.s3.delete_object(Bucket=self.bucket, Key=self._owner_key(blob_id))
|