mcp-gatehouse 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.
- mcp_gatehouse-0.1.0/.github/workflows/ci.yml +20 -0
- mcp_gatehouse-0.1.0/.gitignore +8 -0
- mcp_gatehouse-0.1.0/LICENSE +21 -0
- mcp_gatehouse-0.1.0/PKG-INFO +163 -0
- mcp_gatehouse-0.1.0/README.md +137 -0
- mcp_gatehouse-0.1.0/SECURITY.md +20 -0
- mcp_gatehouse-0.1.0/examples/orders_server.py +59 -0
- mcp_gatehouse-0.1.0/pyproject.toml +50 -0
- mcp_gatehouse-0.1.0/server.json +24 -0
- mcp_gatehouse-0.1.0/src/mcp_gatehouse/__init__.py +30 -0
- mcp_gatehouse-0.1.0/src/mcp_gatehouse/audit.py +76 -0
- mcp_gatehouse-0.1.0/src/mcp_gatehouse/demo.py +92 -0
- mcp_gatehouse-0.1.0/src/mcp_gatehouse/gatehouse.py +200 -0
- mcp_gatehouse-0.1.0/src/mcp_gatehouse/policy.py +124 -0
- mcp_gatehouse-0.1.0/tests/conftest.py +6 -0
- mcp_gatehouse-0.1.0/tests/test_gatehouse.py +184 -0
- mcp_gatehouse-0.1.0/tests/test_policy.py +60 -0
- mcp_gatehouse-0.1.0/tests/test_review_findings.py +160 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- run: pip install -e ".[dev]"
|
|
20
|
+
- run: pytest -q
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nick George
|
|
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.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-gatehouse
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Permission tiers, approval gates, and audit logging for MCP servers — the server is the gatekeeper.
|
|
5
|
+
Project-URL: Homepage, https://nickgeorgeai.com/mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/nickgeorgeseo/mcp-gatehouse
|
|
7
|
+
Author-email: Nick George <nick@nickgeorgeai.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agents,audit-log,mcp,model-context-protocol,permissions,security
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: mcp<2,>=1.27
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: anyio>=4; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# mcp-gatehouse
|
|
28
|
+
|
|
29
|
+
<!-- mcp-name: io.github.nickgeorgeseo/gatehouse -->
|
|
30
|
+
|
|
31
|
+
[](https://github.com/nickgeorgeseo/mcp-gatehouse/actions/workflows/ci.yml)
|
|
32
|
+
[](https://pypi.org/project/mcp-gatehouse/)
|
|
33
|
+
[](https://pypi.org/project/mcp-gatehouse/)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
|
|
36
|
+
**Permission tiers, approval gates, and audit logging for MCP servers.**
|
|
37
|
+
The server is the gatekeeper: you decide what an AI can read, what it can
|
|
38
|
+
write, and what's off-limits — and every action gets logged.
|
|
39
|
+
|
|
40
|
+
Most MCP servers hand the model every tool at full strength and keep no
|
|
41
|
+
record of what it did. That's fine for a demo. It's not fine the day an
|
|
42
|
+
agent has write access to your CRM, your books, or your order system.
|
|
43
|
+
`mcp-gatehouse` is the missing gate, enforced **inside** the server — no
|
|
44
|
+
proxy, no external policy service, no dependencies beyond the official
|
|
45
|
+
[`mcp` SDK](https://github.com/modelcontextprotocol/python-sdk).
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
pip install mcp-gatehouse
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## What you get
|
|
52
|
+
|
|
53
|
+
| | |
|
|
54
|
+
|---|---|
|
|
55
|
+
| **Permission tiers** | Every tool is declared `READ`, `WRITE`, or `DESTRUCTIVE` — and the tier also emits honest spec `ToolAnnotations` (`readOnlyHint` / `destructiveHint`), which the wrapper won't let you override to lie. |
|
|
56
|
+
| **Approval gates** | Tiers you choose require a sign-off before the tool runs. Your approver is any callable — a terminal prompt, a Slack ping, a ticket. **Fails closed:** a gated tool with no approver configured is denied, not waved through. |
|
|
57
|
+
| **Audit log** | Append-only JSONL, one line per call — allowed, denied, or failed — with UTC timestamps and durations. The answer to "what did the AI actually do?" six months later. |
|
|
58
|
+
| **Redaction** | Argument keys you name (`api_key`, `password`, `token`, … by default) are masked before they reach the log *or* the approver. |
|
|
59
|
+
| **Denylist** | Block a tool outright, whatever its tier. |
|
|
60
|
+
|
|
61
|
+
## Quickstart
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from mcp.server.fastmcp import FastMCP
|
|
65
|
+
from mcp_gatehouse import AccessTier, AuditLog, Gatehouse, Policy
|
|
66
|
+
|
|
67
|
+
mcp = FastMCP("order-desk")
|
|
68
|
+
gatehouse = Gatehouse(
|
|
69
|
+
mcp,
|
|
70
|
+
policy=Policy(approver=lambda req: input(f"allow {req.tool}? [y/N] ") == "y"),
|
|
71
|
+
audit=AuditLog(path="audit.jsonl"),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@gatehouse.tool(tier=AccessTier.READ)
|
|
75
|
+
def lookup_order(order_id: str) -> str:
|
|
76
|
+
"""Look up an order's status."""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
@gatehouse.tool(tier=AccessTier.DESTRUCTIVE)
|
|
80
|
+
def cancel_order(order_id: str) -> str:
|
|
81
|
+
"""Cancel an order. Runs only if the approver says yes."""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
mcp.run()
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
That's the whole integration: build your `FastMCP` server exactly as the
|
|
88
|
+
SDK docs show, but register tools through the gatehouse. Schema generation,
|
|
89
|
+
transports, and everything else work unchanged — the guard preserves the
|
|
90
|
+
function's signature.
|
|
91
|
+
|
|
92
|
+
Under the default policy, `DESTRUCTIVE` requires approval and everything
|
|
93
|
+
is audited. Gate writes too with one line:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
Policy(require_approval=frozenset({AccessTier.WRITE, AccessTier.DESTRUCTIVE}), ...)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
What the audit trail looks like:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{"ts": "2026-07-16T14:02:11+00:00", "tool": "lookup_order", "tier": "read", "outcome": "ok", "arguments": {"order_id": "4417"}, "duration_ms": 0.42}
|
|
103
|
+
{"ts": "2026-07-16T14:02:38+00:00", "tool": "add_note", "tier": "write", "outcome": "ok", "arguments": {"order_id": "4417", "note": "call back", "api_key": "«redacted»"}, "duration_ms": 1.08}
|
|
104
|
+
{"ts": "2026-07-16T14:03:05+00:00", "tool": "cancel_order", "tier": "destructive", "outcome": "denied", "reason": "approver refused", "arguments": {"order_id": "4417"}}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Try the demo
|
|
108
|
+
|
|
109
|
+
The package ships a runnable order-desk server with all three tiers wired
|
|
110
|
+
up and a terminal-prompt approver:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
mcp-gatehouse-demo
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Point any MCP client at it over stdio (Claude Desktop, etc.), ask the model
|
|
117
|
+
to cancel an order, and watch the approval land in your terminal — and the
|
|
118
|
+
verdict land in `audit.jsonl` either way. `examples/orders_server.py` is
|
|
119
|
+
the same server as a copyable template.
|
|
120
|
+
|
|
121
|
+
## Design notes
|
|
122
|
+
|
|
123
|
+
- **Enforcement lives inside the server**, at the tool boundary. A proxy
|
|
124
|
+
can't see your tools' semantics, and a policy service is one more thing
|
|
125
|
+
to deploy. A 40-person plant doesn't have a platform team; this is a few
|
|
126
|
+
small classes and a JSONL file.
|
|
127
|
+
- **Fail closed.** Security defaults that quietly allow are worse than none.
|
|
128
|
+
That includes redaction: argument values the scrubber can't take apart
|
|
129
|
+
(arbitrary objects, bytes) are replaced with an opaque placeholder rather
|
|
130
|
+
than passed through, and exception *messages* stay out of the log —
|
|
131
|
+
only the exception type is recorded, because error text loves to embed
|
|
132
|
+
the very values you just redacted.
|
|
133
|
+
- **The audit log records denials and errors**, not just successes — the
|
|
134
|
+
calls that *didn't* happen are half the story.
|
|
135
|
+
- **A blocking terminal approver and the stdio transport don't mix** —
|
|
136
|
+
stdout/stdin are the protocol pipe. The demo's approver prompts on
|
|
137
|
+
`/dev/tty` for exactly that reason (and denies when no terminal exists).
|
|
138
|
+
Real deployments should approve out-of-band: Slack, a ticket, a queue.
|
|
139
|
+
- **What this is not:** authentication, transport encryption, or a sandbox.
|
|
140
|
+
It's a gate inside your server, not a perimeter around it. See
|
|
141
|
+
[SECURITY.md](SECURITY.md).
|
|
142
|
+
|
|
143
|
+
## Compatibility
|
|
144
|
+
|
|
145
|
+
Targets the official [`mcp` Python SDK](https://github.com/modelcontextprotocol/python-sdk)
|
|
146
|
+
v1.x (`mcp>=1.27,<2`) and Python 3.10+. When SDK v2 ships for the
|
|
147
|
+
2026-07-28 spec revision, a v2-compatible release will follow — the
|
|
148
|
+
public API here (`Gatehouse`, `Policy`, `AuditLog`, `AccessTier`) will
|
|
149
|
+
not change.
|
|
150
|
+
|
|
151
|
+
## Who built this
|
|
152
|
+
|
|
153
|
+
[Nick George](https://nickgeorgeai.com) — I design and run MCP servers in
|
|
154
|
+
production for a mid-market reverse logistics-tech company, and build them
|
|
155
|
+
for businesses at [nickgeorgeai.com](https://nickgeorgeai.com). This
|
|
156
|
+
library is the permission-and-audit discipline from those builds, extracted.
|
|
157
|
+
|
|
158
|
+
If you're an owner or operator wondering what MCP even is, start with the
|
|
159
|
+
plain-English guide: [What is an MCP server?](https://nickgeorgeai.com/mcp)
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# mcp-gatehouse
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.nickgeorgeseo/gatehouse -->
|
|
4
|
+
|
|
5
|
+
[](https://github.com/nickgeorgeseo/mcp-gatehouse/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/mcp-gatehouse/)
|
|
7
|
+
[](https://pypi.org/project/mcp-gatehouse/)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
|
|
10
|
+
**Permission tiers, approval gates, and audit logging for MCP servers.**
|
|
11
|
+
The server is the gatekeeper: you decide what an AI can read, what it can
|
|
12
|
+
write, and what's off-limits — and every action gets logged.
|
|
13
|
+
|
|
14
|
+
Most MCP servers hand the model every tool at full strength and keep no
|
|
15
|
+
record of what it did. That's fine for a demo. It's not fine the day an
|
|
16
|
+
agent has write access to your CRM, your books, or your order system.
|
|
17
|
+
`mcp-gatehouse` is the missing gate, enforced **inside** the server — no
|
|
18
|
+
proxy, no external policy service, no dependencies beyond the official
|
|
19
|
+
[`mcp` SDK](https://github.com/modelcontextprotocol/python-sdk).
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
pip install mcp-gatehouse
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## What you get
|
|
26
|
+
|
|
27
|
+
| | |
|
|
28
|
+
|---|---|
|
|
29
|
+
| **Permission tiers** | Every tool is declared `READ`, `WRITE`, or `DESTRUCTIVE` — and the tier also emits honest spec `ToolAnnotations` (`readOnlyHint` / `destructiveHint`), which the wrapper won't let you override to lie. |
|
|
30
|
+
| **Approval gates** | Tiers you choose require a sign-off before the tool runs. Your approver is any callable — a terminal prompt, a Slack ping, a ticket. **Fails closed:** a gated tool with no approver configured is denied, not waved through. |
|
|
31
|
+
| **Audit log** | Append-only JSONL, one line per call — allowed, denied, or failed — with UTC timestamps and durations. The answer to "what did the AI actually do?" six months later. |
|
|
32
|
+
| **Redaction** | Argument keys you name (`api_key`, `password`, `token`, … by default) are masked before they reach the log *or* the approver. |
|
|
33
|
+
| **Denylist** | Block a tool outright, whatever its tier. |
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from mcp.server.fastmcp import FastMCP
|
|
39
|
+
from mcp_gatehouse import AccessTier, AuditLog, Gatehouse, Policy
|
|
40
|
+
|
|
41
|
+
mcp = FastMCP("order-desk")
|
|
42
|
+
gatehouse = Gatehouse(
|
|
43
|
+
mcp,
|
|
44
|
+
policy=Policy(approver=lambda req: input(f"allow {req.tool}? [y/N] ") == "y"),
|
|
45
|
+
audit=AuditLog(path="audit.jsonl"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@gatehouse.tool(tier=AccessTier.READ)
|
|
49
|
+
def lookup_order(order_id: str) -> str:
|
|
50
|
+
"""Look up an order's status."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
@gatehouse.tool(tier=AccessTier.DESTRUCTIVE)
|
|
54
|
+
def cancel_order(order_id: str) -> str:
|
|
55
|
+
"""Cancel an order. Runs only if the approver says yes."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
mcp.run()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
That's the whole integration: build your `FastMCP` server exactly as the
|
|
62
|
+
SDK docs show, but register tools through the gatehouse. Schema generation,
|
|
63
|
+
transports, and everything else work unchanged — the guard preserves the
|
|
64
|
+
function's signature.
|
|
65
|
+
|
|
66
|
+
Under the default policy, `DESTRUCTIVE` requires approval and everything
|
|
67
|
+
is audited. Gate writes too with one line:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
Policy(require_approval=frozenset({AccessTier.WRITE, AccessTier.DESTRUCTIVE}), ...)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
What the audit trail looks like:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{"ts": "2026-07-16T14:02:11+00:00", "tool": "lookup_order", "tier": "read", "outcome": "ok", "arguments": {"order_id": "4417"}, "duration_ms": 0.42}
|
|
77
|
+
{"ts": "2026-07-16T14:02:38+00:00", "tool": "add_note", "tier": "write", "outcome": "ok", "arguments": {"order_id": "4417", "note": "call back", "api_key": "«redacted»"}, "duration_ms": 1.08}
|
|
78
|
+
{"ts": "2026-07-16T14:03:05+00:00", "tool": "cancel_order", "tier": "destructive", "outcome": "denied", "reason": "approver refused", "arguments": {"order_id": "4417"}}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Try the demo
|
|
82
|
+
|
|
83
|
+
The package ships a runnable order-desk server with all three tiers wired
|
|
84
|
+
up and a terminal-prompt approver:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
mcp-gatehouse-demo
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Point any MCP client at it over stdio (Claude Desktop, etc.), ask the model
|
|
91
|
+
to cancel an order, and watch the approval land in your terminal — and the
|
|
92
|
+
verdict land in `audit.jsonl` either way. `examples/orders_server.py` is
|
|
93
|
+
the same server as a copyable template.
|
|
94
|
+
|
|
95
|
+
## Design notes
|
|
96
|
+
|
|
97
|
+
- **Enforcement lives inside the server**, at the tool boundary. A proxy
|
|
98
|
+
can't see your tools' semantics, and a policy service is one more thing
|
|
99
|
+
to deploy. A 40-person plant doesn't have a platform team; this is a few
|
|
100
|
+
small classes and a JSONL file.
|
|
101
|
+
- **Fail closed.** Security defaults that quietly allow are worse than none.
|
|
102
|
+
That includes redaction: argument values the scrubber can't take apart
|
|
103
|
+
(arbitrary objects, bytes) are replaced with an opaque placeholder rather
|
|
104
|
+
than passed through, and exception *messages* stay out of the log —
|
|
105
|
+
only the exception type is recorded, because error text loves to embed
|
|
106
|
+
the very values you just redacted.
|
|
107
|
+
- **The audit log records denials and errors**, not just successes — the
|
|
108
|
+
calls that *didn't* happen are half the story.
|
|
109
|
+
- **A blocking terminal approver and the stdio transport don't mix** —
|
|
110
|
+
stdout/stdin are the protocol pipe. The demo's approver prompts on
|
|
111
|
+
`/dev/tty` for exactly that reason (and denies when no terminal exists).
|
|
112
|
+
Real deployments should approve out-of-band: Slack, a ticket, a queue.
|
|
113
|
+
- **What this is not:** authentication, transport encryption, or a sandbox.
|
|
114
|
+
It's a gate inside your server, not a perimeter around it. See
|
|
115
|
+
[SECURITY.md](SECURITY.md).
|
|
116
|
+
|
|
117
|
+
## Compatibility
|
|
118
|
+
|
|
119
|
+
Targets the official [`mcp` Python SDK](https://github.com/modelcontextprotocol/python-sdk)
|
|
120
|
+
v1.x (`mcp>=1.27,<2`) and Python 3.10+. When SDK v2 ships for the
|
|
121
|
+
2026-07-28 spec revision, a v2-compatible release will follow — the
|
|
122
|
+
public API here (`Gatehouse`, `Policy`, `AuditLog`, `AccessTier`) will
|
|
123
|
+
not change.
|
|
124
|
+
|
|
125
|
+
## Who built this
|
|
126
|
+
|
|
127
|
+
[Nick George](https://nickgeorgeai.com) — I design and run MCP servers in
|
|
128
|
+
production for a mid-market reverse logistics-tech company, and build them
|
|
129
|
+
for businesses at [nickgeorgeai.com](https://nickgeorgeai.com). This
|
|
130
|
+
library is the permission-and-audit discipline from those builds, extracted.
|
|
131
|
+
|
|
132
|
+
If you're an owner or operator wondering what MCP even is, start with the
|
|
133
|
+
plain-English guide: [What is an MCP server?](https://nickgeorgeai.com/mcp)
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
`mcp-gatehouse` is security *tooling*, so it gets held to its own standard.
|
|
4
|
+
|
|
5
|
+
## Reporting a vulnerability
|
|
6
|
+
|
|
7
|
+
Email **nick@nickgeorgeai.com** with the details. You'll get a reply
|
|
8
|
+
within 72 hours. Please don't open a public issue for anything exploitable —
|
|
9
|
+
give me a chance to ship the fix first.
|
|
10
|
+
|
|
11
|
+
## What this library does and doesn't do
|
|
12
|
+
|
|
13
|
+
**Does:** enforce per-tool permission tiers, gate calls behind an approver
|
|
14
|
+
you control, keep an append-only audit trail, and mask configured argument
|
|
15
|
+
keys before they reach logs or approvers.
|
|
16
|
+
|
|
17
|
+
**Doesn't:** authenticate clients, encrypt transports, sandbox tool code,
|
|
18
|
+
or protect against a malicious *server author*. It's a gate inside your
|
|
19
|
+
server, not a perimeter around it. Treat third-party MCP servers with the
|
|
20
|
+
same scrutiny you'd give any software you install.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""A copyable template: the demo order-desk server, spelled out.
|
|
2
|
+
|
|
3
|
+
This is the same server `mcp-gatehouse-demo` runs — copy it and swap the
|
|
4
|
+
ORDERS dict for your real system. The approver comes from the package
|
|
5
|
+
because prompting a human is trickier than it looks: over the stdio
|
|
6
|
+
transport, stdout/stdin are the protocol pipe, so the prompt must go to
|
|
7
|
+
the controlling terminal (and fail closed when there isn't one).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from mcp.server.fastmcp import FastMCP
|
|
13
|
+
|
|
14
|
+
from mcp_gatehouse import AccessTier, AuditLog, Gatehouse, Policy
|
|
15
|
+
from mcp_gatehouse.demo import terminal_approver
|
|
16
|
+
|
|
17
|
+
# A stand-in for your real system of record.
|
|
18
|
+
ORDERS = {
|
|
19
|
+
"4417": {"customer": "Blue Ridge Cabinets", "status": "packed", "notes": []},
|
|
20
|
+
"4418": {"customer": "Piedmont Supply", "status": "picking", "notes": []},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
mcp = FastMCP("order-desk")
|
|
24
|
+
gatehouse = Gatehouse(
|
|
25
|
+
mcp,
|
|
26
|
+
policy=Policy(approver=terminal_approver),
|
|
27
|
+
audit=AuditLog(path="audit.jsonl"),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@gatehouse.tool(tier=AccessTier.READ)
|
|
32
|
+
def lookup_order(order_id: str) -> str:
|
|
33
|
+
"""Look up an order's status."""
|
|
34
|
+
order = ORDERS.get(order_id)
|
|
35
|
+
if order is None:
|
|
36
|
+
return f"no order {order_id}"
|
|
37
|
+
return f"{order_id} · {order['customer']} · {order['status']}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@gatehouse.tool(tier=AccessTier.WRITE)
|
|
41
|
+
def add_note(order_id: str, note: str, api_key: str = "") -> str:
|
|
42
|
+
"""Attach a note to an order. (The api_key never reaches the log.)"""
|
|
43
|
+
if order_id not in ORDERS:
|
|
44
|
+
return f"no order {order_id}"
|
|
45
|
+
ORDERS[order_id]["notes"].append(note)
|
|
46
|
+
return f"note added to {order_id}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@gatehouse.tool(tier=AccessTier.DESTRUCTIVE)
|
|
50
|
+
def cancel_order(order_id: str) -> str:
|
|
51
|
+
"""Cancel an order. Requires approval — the gate fails closed."""
|
|
52
|
+
if order_id not in ORDERS:
|
|
53
|
+
return f"no order {order_id}"
|
|
54
|
+
ORDERS[order_id]["status"] = "cancelled"
|
|
55
|
+
return f"{order_id} cancelled"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
mcp.run()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mcp-gatehouse"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Permission tiers, approval gates, and audit logging for MCP servers — the server is the gatekeeper."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Nick George", email = "nick@nickgeorgeai.com" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"mcp",
|
|
15
|
+
"model-context-protocol",
|
|
16
|
+
"ai-agents",
|
|
17
|
+
"security",
|
|
18
|
+
"audit-log",
|
|
19
|
+
"permissions",
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 4 - Beta",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Topic :: Software Development :: Libraries",
|
|
30
|
+
"Topic :: Security",
|
|
31
|
+
]
|
|
32
|
+
dependencies = ["mcp>=1.27,<2"]
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
mcp-gatehouse-demo = "mcp_gatehouse.demo:main"
|
|
36
|
+
# Alias so `uvx mcp-gatehouse` / `pipx run mcp-gatehouse` resolve without --from.
|
|
37
|
+
mcp-gatehouse = "mcp_gatehouse.demo:main"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://nickgeorgeai.com/mcp"
|
|
41
|
+
Repository = "https://github.com/nickgeorgeseo/mcp-gatehouse"
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
dev = ["pytest>=8", "anyio>=4"]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/mcp_gatehouse"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.nickgeorgeseo/gatehouse",
|
|
4
|
+
"title": "mcp-gatehouse",
|
|
5
|
+
"description": "Permission tiers, approval gates, and audit logging for MCP servers, enforced inside the server at the tool boundary. Ships a runnable order-desk demo: READ tools run freely, WRITE tools are audited with secret redaction, DESTRUCTIVE tools require human approval and fail closed.",
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"websiteUrl": "https://nickgeorgeai.com/mcp",
|
|
8
|
+
"repository": {
|
|
9
|
+
"url": "https://github.com/nickgeorgeseo/mcp-gatehouse",
|
|
10
|
+
"source": "github"
|
|
11
|
+
},
|
|
12
|
+
"packages": [
|
|
13
|
+
{
|
|
14
|
+
"registryType": "pypi",
|
|
15
|
+
"registryBaseUrl": "https://pypi.org",
|
|
16
|
+
"identifier": "mcp-gatehouse",
|
|
17
|
+
"version": "0.1.0",
|
|
18
|
+
"runtimeHint": "uvx",
|
|
19
|
+
"transport": {
|
|
20
|
+
"type": "stdio"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""mcp-gatehouse — permission tiers, approval gates, and audit logging
|
|
2
|
+
for MCP servers built with the official Python SDK.
|
|
3
|
+
|
|
4
|
+
The server is the gatekeeper: you decide what an AI can read, what it can
|
|
5
|
+
write, and what's off-limits — and every action gets logged.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .audit import AuditLog, NullAuditLog
|
|
9
|
+
from .gatehouse import GateDenied, Gatehouse
|
|
10
|
+
from .policy import (
|
|
11
|
+
AccessTier,
|
|
12
|
+
ApprovalRequest,
|
|
13
|
+
Approver,
|
|
14
|
+
Policy,
|
|
15
|
+
redact_arguments,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AccessTier",
|
|
20
|
+
"ApprovalRequest",
|
|
21
|
+
"Approver",
|
|
22
|
+
"AuditLog",
|
|
23
|
+
"GateDenied",
|
|
24
|
+
"Gatehouse",
|
|
25
|
+
"NullAuditLog",
|
|
26
|
+
"Policy",
|
|
27
|
+
"redact_arguments",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Append-only JSONL audit log.
|
|
2
|
+
|
|
3
|
+
One line per tool call — allowed, denied, or failed — with UTC timestamps
|
|
4
|
+
and redacted arguments. The point is a trail you can grep six months later
|
|
5
|
+
when someone asks "what did the AI actually do?"
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as _dt
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, TextIO
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuditLog:
|
|
20
|
+
"""Writes one JSON object per line to a file or stream.
|
|
21
|
+
|
|
22
|
+
Thread-safe for concurrent tool calls within one process. Events are
|
|
23
|
+
flushed on every write — an audit log that loses its tail in a crash
|
|
24
|
+
is not an audit log.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
path: str | os.PathLike[str] | None = None,
|
|
30
|
+
stream: TextIO | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
if (path is None) == (stream is None):
|
|
33
|
+
raise ValueError("provide exactly one of `path` or `stream`")
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
if path is not None:
|
|
36
|
+
p = Path(path)
|
|
37
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
self._stream: TextIO = p.open("a", encoding="utf-8")
|
|
39
|
+
self._owns_stream = True
|
|
40
|
+
else:
|
|
41
|
+
assert stream is not None
|
|
42
|
+
self._stream = stream
|
|
43
|
+
self._owns_stream = False
|
|
44
|
+
|
|
45
|
+
def record(self, **event: Any) -> None:
|
|
46
|
+
"""Append one event. A ``ts`` field (UTC, ISO-8601) is added.
|
|
47
|
+
|
|
48
|
+
``ensure_ascii=True`` on purpose: it escapes U+2028/U+2029/NEL and
|
|
49
|
+
friends, so a crafted argument value can never split one record
|
|
50
|
+
into two for line-aware readers. One ``\\n`` per record, always.
|
|
51
|
+
"""
|
|
52
|
+
event = {"ts": _dt.datetime.now(_dt.timezone.utc).isoformat(), **event}
|
|
53
|
+
line = json.dumps(event, ensure_ascii=True, default=str)
|
|
54
|
+
with self._lock:
|
|
55
|
+
self._stream.write(line + "\n")
|
|
56
|
+
self._stream.flush()
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
if self._owns_stream:
|
|
60
|
+
self._stream.close()
|
|
61
|
+
|
|
62
|
+
def __enter__(self) -> "AuditLog":
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(self, *exc: object) -> None:
|
|
66
|
+
self.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class NullAuditLog(AuditLog):
|
|
70
|
+
"""An audit log that swallows everything. For tests and dry runs."""
|
|
71
|
+
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
super().__init__(stream=io.StringIO())
|
|
74
|
+
|
|
75
|
+
def record(self, **event: Any) -> None: # noqa: D102 — intentionally inert
|
|
76
|
+
pass
|