Skip to content

Commit 328bc8d

Browse files
committed
feat(examples): add postgres-knowledge-server with authorization middleware
1 parent 3d7b311 commit 328bc8d

5 files changed

Lines changed: 332 additions & 0 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Postgres Knowledge Server
2+
3+
A production-grade MCP server backed by PostgreSQL with authorization middleware.
4+
5+
Demonstrates:
6+
- **Multi-tool MCP server** — knowledge store, task queue, file routing
7+
- **Authorization middleware** — filesystem-based identity gate (no ACL database)
8+
- **Postgres backend** — Unix socket connection, no host/port exposure
9+
- **Portless stdio transport** — no HTTP server, no open ports
10+
11+
## Architecture
12+
13+
```
14+
MCP Client (Claude Code, etc.)
15+
│ stdio
16+
17+
sap/sap_mcp.py ← authorization gate + FastMCP server
18+
19+
├── willow_store ← SQLite local store (30+ tools)
20+
├── postgres KB ← knowledge graph (atoms, entities)
21+
└── kart queue ← sandboxed task executor
22+
```
23+
24+
## Authorization Pattern
25+
26+
Instead of a permission database, authorization is filesystem-based:
27+
28+
```python
29+
SAFE_ROOT = Path.home() / "Ashokoa" / "SAFE"
30+
31+
def authorized(app_id: str) -> bool:
32+
"""Agent has a SAFE folder → it has access. No folder → denied."""
33+
folder = SAFE_ROOT / app_id
34+
return folder.exists() and (folder / "manifest").exists()
35+
```
36+
37+
Grant access: `mkdir -p ~/Ashokoa/SAFE/my-agent && touch ~/Ashokoa/SAFE/my-agent/manifest`
38+
Revoke access: `rm -rf ~/Ashokoa/SAFE/my-agent`
39+
40+
The filesystem shape IS the identity. No separate ACL.
41+
42+
## Running
43+
44+
```bash
45+
# Install
46+
pip install mcp psycopg2-binary
47+
48+
# Configure Postgres (Unix socket — no host/port)
49+
createdb myknowledge
50+
51+
# Run
52+
python server.py
53+
```
54+
55+
## MCP Config (Claude Code)
56+
57+
```json
58+
{
59+
"mcpServers": {
60+
"knowledge": {
61+
"command": "python",
62+
"args": ["/path/to/server.py"],
63+
"env": {
64+
"WILLOW_PG_DB": "myknowledge",
65+
"WILLOW_PG_USER": "myuser",
66+
"WILLOW_AGENT_NAME": "myagent"
67+
}
68+
}
69+
}
70+
}
71+
```

examples/servers/postgres-knowledge-server/mcp_postgres_server/__init__.py

Whitespace-only changes.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from mcp_postgres_server.server import main
2+
3+
main()
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
"""
2+
Postgres-backed MCP server with filesystem-based authorization.
3+
4+
Demonstrates:
5+
- Multi-tool MCP server (knowledge store read/write + search)
6+
- Authorization middleware: filesystem gate, no ACL database
7+
- Postgres backend via Unix socket (portless, no host/port exposure)
8+
- stdio-only transport (no HTTP server)
9+
10+
Usage:
11+
Set env vars: WILLOW_PG_DB, WILLOW_PG_USER, WILLOW_SAFE_ROOT
12+
Grant access: mkdir -p $WILLOW_SAFE_ROOT/my-app && echo '{}' > $WILLOW_SAFE_ROOT/my-app/manifest
13+
Run: python -m mcp_postgres_server
14+
"""
15+
16+
import json
17+
import os
18+
from pathlib import Path
19+
20+
import anyio
21+
import click
22+
import psycopg2
23+
import psycopg2.extras
24+
from mcp import types
25+
from mcp.server import Server, ServerRequestContext
26+
27+
# ---------------------------------------------------------------------------
28+
# Configuration
29+
# ---------------------------------------------------------------------------
30+
31+
PG_DB = os.environ.get("WILLOW_PG_DB", "knowledge")
32+
PG_USER = os.environ.get("WILLOW_PG_USER", os.environ.get("USER", "postgres"))
33+
SAFE_ROOT = Path(os.environ.get("WILLOW_SAFE_ROOT", Path.home() / "SAFE"))
34+
35+
36+
# ---------------------------------------------------------------------------
37+
# Authorization gate
38+
# ---------------------------------------------------------------------------
39+
40+
def authorized(app_id: str) -> bool:
41+
"""Filesystem-based authorization: folder exists → access granted.
42+
43+
No permission database. The presence of the folder IS the permission.
44+
Grant: mkdir -p $SAFE_ROOT/<app_id> && touch $SAFE_ROOT/<app_id>/manifest
45+
Revoke: rm -rf $SAFE_ROOT/<app_id>
46+
"""
47+
if not app_id or "/" in app_id or ".." in app_id:
48+
return False
49+
folder = SAFE_ROOT / app_id
50+
return folder.is_dir() and (folder / "manifest").exists()
51+
52+
53+
# ---------------------------------------------------------------------------
54+
# Postgres helpers
55+
# ---------------------------------------------------------------------------
56+
57+
def get_conn():
58+
return psycopg2.connect(dbname=PG_DB, user=PG_USER)
59+
60+
61+
def ensure_schema(conn):
62+
with conn.cursor() as cur:
63+
cur.execute("""
64+
CREATE TABLE IF NOT EXISTS knowledge (
65+
id TEXT PRIMARY KEY,
66+
app_id TEXT NOT NULL,
67+
title TEXT,
68+
body TEXT,
69+
created TIMESTAMPTZ DEFAULT now()
70+
)
71+
""")
72+
cur.execute("CREATE INDEX IF NOT EXISTS knowledge_app ON knowledge(app_id)")
73+
cur.execute("CREATE INDEX IF NOT EXISTS knowledge_fts ON knowledge USING gin(to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,'')))")
74+
conn.commit()
75+
76+
77+
# ---------------------------------------------------------------------------
78+
# Tools
79+
# ---------------------------------------------------------------------------
80+
81+
TOOLS = [
82+
types.Tool(
83+
name="knowledge_put",
84+
title="Store Knowledge",
85+
description="Write a record to the knowledge base.",
86+
input_schema={
87+
"type": "object",
88+
"required": ["app_id", "id", "title", "body"],
89+
"properties": {
90+
"app_id": {"type": "string", "description": "Authorized app identifier"},
91+
"id": {"type": "string", "description": "Unique record ID"},
92+
"title": {"type": "string", "description": "Record title"},
93+
"body": {"type": "string", "description": "Record content"},
94+
},
95+
},
96+
),
97+
types.Tool(
98+
name="knowledge_get",
99+
title="Get Knowledge",
100+
description="Retrieve a record by ID.",
101+
input_schema={
102+
"type": "object",
103+
"required": ["app_id", "id"],
104+
"properties": {
105+
"app_id": {"type": "string"},
106+
"id": {"type": "string"},
107+
},
108+
},
109+
),
110+
types.Tool(
111+
name="knowledge_search",
112+
title="Search Knowledge",
113+
description="Full-text search across the knowledge base.",
114+
input_schema={
115+
"type": "object",
116+
"required": ["app_id", "query"],
117+
"properties": {
118+
"app_id": {"type": "string"},
119+
"query": {"type": "string"},
120+
"limit": {"type": "integer", "default": 10},
121+
},
122+
},
123+
),
124+
]
125+
126+
127+
async def handle_list_tools(
128+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
129+
) -> types.ListToolsResult:
130+
return types.ListToolsResult(tools=TOOLS)
131+
132+
133+
async def handle_call_tool(
134+
ctx: ServerRequestContext, params: types.CallToolRequestParams
135+
) -> types.CallToolResult:
136+
args = params.arguments or {}
137+
app_id = args.get("app_id", "")
138+
139+
if not authorized(app_id):
140+
return types.CallToolResult(
141+
content=[types.TextContent(type="text", text=f"Unauthorized: no SAFE folder for '{app_id}'")],
142+
isError=True,
143+
)
144+
145+
try:
146+
conn = get_conn()
147+
ensure_schema(conn)
148+
149+
if params.name == "knowledge_put":
150+
with conn.cursor() as cur:
151+
cur.execute(
152+
"INSERT INTO knowledge (id, app_id, title, body) VALUES (%s, %s, %s, %s)"
153+
" ON CONFLICT (id) DO UPDATE SET title=EXCLUDED.title, body=EXCLUDED.body",
154+
(args["id"], app_id, args["title"], args["body"]),
155+
)
156+
conn.commit()
157+
result = {"id": args["id"], "action": "stored"}
158+
159+
elif params.name == "knowledge_get":
160+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
161+
cur.execute("SELECT id, title, body, created FROM knowledge WHERE id=%s AND app_id=%s",
162+
(args["id"], app_id))
163+
row = cur.fetchone()
164+
result = dict(row) if row else {"error": "not_found"}
165+
166+
elif params.name == "knowledge_search":
167+
limit = min(int(args.get("limit", 10)), 50)
168+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
169+
cur.execute(
170+
"SELECT id, title, created FROM knowledge"
171+
" WHERE app_id=%s AND to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''))"
172+
" @@ plainto_tsquery('english', %s)"
173+
" LIMIT %s",
174+
(app_id, args["query"], limit),
175+
)
176+
result = [dict(r) for r in cur.fetchall()]
177+
178+
else:
179+
return types.CallToolResult(
180+
content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")],
181+
isError=True,
182+
)
183+
184+
conn.close()
185+
return types.CallToolResult(
186+
content=[types.TextContent(type="text", text=json.dumps(result, default=str))]
187+
)
188+
189+
except Exception as exc:
190+
return types.CallToolResult(
191+
content=[types.TextContent(type="text", text=f"Error: {exc}")],
192+
isError=True,
193+
)
194+
195+
196+
# ---------------------------------------------------------------------------
197+
# Entry point
198+
# ---------------------------------------------------------------------------
199+
200+
@click.command()
201+
def main():
202+
"""Postgres-backed MCP knowledge server (stdio transport)."""
203+
app = Server(
204+
"mcp-postgres-knowledge",
205+
on_list_tools=handle_list_tools,
206+
on_call_tool=handle_call_tool,
207+
)
208+
209+
from mcp.server.stdio import stdio_server
210+
211+
async def arun():
212+
async with stdio_server() as streams:
213+
await app.run(streams[0], streams[1], app.create_initialization_options())
214+
215+
anyio.run(arun)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
[project]
2+
name = "mcp-postgres-knowledge"
3+
version = "0.1.0"
4+
description = "Postgres-backed MCP server with filesystem-based authorization"
5+
readme = "README.md"
6+
requires-python = ">=3.10"
7+
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
8+
keywords = ["mcp", "postgres", "knowledge", "authorization"]
9+
license = { text = "MIT" }
10+
classifiers = [
11+
"Development Status :: 4 - Beta",
12+
"Intended Audience :: Developers",
13+
"License :: OSI Approved :: MIT License",
14+
"Programming Language :: Python :: 3",
15+
"Programming Language :: Python :: 3.10",
16+
]
17+
dependencies = ["anyio>=4.5", "click>=8.2.0", "mcp", "psycopg2-binary>=2.9"]
18+
19+
[project.scripts]
20+
mcp-postgres-knowledge = "mcp_postgres_server.server:main"
21+
22+
[build-system]
23+
requires = ["hatchling"]
24+
build-backend = "hatchling.build"
25+
26+
[tool.hatch.build.targets.wheel]
27+
packages = ["mcp_postgres_server"]
28+
29+
[tool.pyright]
30+
include = ["mcp_postgres_server"]
31+
venvPath = "."
32+
venv = ".venv"
33+
34+
[tool.ruff.lint]
35+
select = ["E", "F", "I"]
36+
ignore = []
37+
38+
[tool.ruff]
39+
line-length = 120
40+
target-version = "py310"
41+
42+
[dependency-groups]
43+
dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"]

0 commit comments

Comments
 (0)