Repository navigation
Expand file tree
/
Copy pathserver.py.diff
More file actions
156 lines (145 loc) · 6.1 KB
/
Copy pathserver.py.diff
File metadata and controls
156 lines (145 loc) · 6.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
--- repos/celticht32/server.py 2026-06-23 00:58:35.031526372 +0000
+++ fix/server.py 2026-06-23 01:01:23.744634929 +0000
@@ -106,6 +106,12 @@
get_confirmation_required,
require_confirmation,
)
+from auth.scope_gate import (
+ check_scope,
+ clear_token_claims,
+ configure as configure_scope_gate,
+ set_token_claims,
+)
# ── Aggregate tool registry ──────────────────────────────────────────────────
@@ -153,6 +159,10 @@
# because they enforce read-only behavior internally (e.g. cb_query rejects DML).
_ALWAYS_LOADED_IN_READ_ONLY: set[str] = {"cb_query", "cb_analytics_query"}
+# Keep scope-gate read/write classification in lockstep with the read-only
+# filter below. A read-scoped token may invoke exactly what loads in read-only.
+configure_scope_gate(_ALWAYS_LOADED_IN_READ_ONLY)
+
def _is_read_only(t: Tool) -> bool:
"""A tool is read-only if its annotation says so."""
@@ -216,6 +226,16 @@
),
)
+ # Per-tool OAuth scope enforcement. No-op on stdio / when OAuth is not
+ # configured (no token in context). On authenticated HTTP the token's
+ # scopes must satisfy the tool's required scope, classified the same way
+ # the read-only filter classifies load eligibility.
+ tool_obj = next((t for t in _TOOLS if t.name == name), None)
+ if tool_obj is not None:
+ denial = check_scope(tool_obj)
+ if denial:
+ return err(denial, tool=name, hint="Token is missing the required scope.")
+
# Confirmation gate for destructive tools.
in_confirm_set = name in _CONFIRMATION_REQUIRED
msg = require_confirmation(name, arguments, in_confirm_set)
@@ -236,6 +256,18 @@
def _startup_banner() -> None:
+ # Footgun guard: OIDC configured but enforcement not required means invalid
+ # tokens are silently ignored on HTTP. Warn so it is a conscious choice.
+ if os.environ.get("OAUTH_ISSUER", "").strip() and os.environ.get(
+ "CB_MCP_HTTP_REQUIRE_AUTH", "false"
+ ).strip().lower() not in ("1", "true", "yes"):
+ print(
+ "[couchbase-mcp] WARNING: OAUTH_ISSUER is set but "
+ "CB_MCP_HTTP_REQUIRE_AUTH is not true -- invalid/missing Bearer "
+ "tokens on the HTTP transport are ignored (no enforcement). Set "
+ "CB_MCP_HTTP_REQUIRE_AUTH=true to enforce.",
+ file=sys.stderr, flush=True,
+ )
msg = (
f"[couchbase-mcp] tools loaded: {len(_TOOLS)} of {len(_RAW_TOOLS)} "
f"(read_only={READ_ONLY_MODE}, disabled={len(DISABLED_TOOLS)}, "
@@ -253,9 +285,77 @@
await app.run(read_stream, write_stream, app.create_initialization_options())
+class _ScopeAuthMiddleware:
+ """ASGI middleware: validate Bearer token (if present) and stash claims in
+ the scope-gate contextvar for the duration of the request.
+
+ Enforcement is OPTIONAL by default. With OAUTH_ISSUER set and
+ CB_MCP_HTTP_REQUIRE_AUTH=true, a missing/invalid token is rejected at the
+ edge. Otherwise an invalid token is ignored (claims stay None) and per-tool
+ scope checks no-op -- preserving today's behavior unless you opt in.
+ """
+
+ def __init__(self, app):
+ self.app = app
+ self._require = os.environ.get(
+ "CB_MCP_HTTP_REQUIRE_AUTH", "false"
+ ).strip().lower() in ("1", "true", "yes")
+
+ async def __call__(self, scope, receive, send):
+ if scope["type"] != "http":
+ await self.app(scope, receive, send)
+ return
+
+ token = None
+ for k, v in scope.get("headers", []):
+ if k == b"authorization":
+ val = v.decode("latin-1")
+ if val.lower().startswith("bearer "):
+ token = val[7:].strip()
+ break
+
+ claims = None
+ if token:
+ try:
+ from auth import oidc as _oidc
+ claims = _oidc.validate_token(token)
+ except Exception as exc: # invalid/expired/malformed -- never log token
+ if self._require:
+ await _send_401(send, f"Invalid token: {type(exc).__name__}")
+ return
+ claims = None
+ elif self._require:
+ await _send_401(send, "Missing Bearer token")
+ return
+
+ set_token_claims(claims)
+ try:
+ await self.app(scope, receive, send)
+ finally:
+ clear_token_claims()
+
+
+async def _send_401(send, detail: str) -> None:
+ body = f'{{"error":"unauthorized","detail":"{detail}"}}'.encode()
+ await send({
+ "type": "http.response.start",
+ "status": 401,
+ "headers": [
+ (b"content-type", b"application/json"),
+ (b"www-authenticate", b"Bearer"),
+ ],
+ })
+ await send({"type": "http.response.body", "body": body})
+
+
async def _main_http() -> None:
- """Streamable HTTP transport. Note: this mode does not include authorization;
- deploy behind a reverse proxy or authenticated network."""
+ """Streamable HTTP transport.
+
+ Request authorization is optional: with OAUTH_ISSUER configured and
+ CB_MCP_HTTP_REQUIRE_AUTH=true, _ScopeAuthMiddleware validates the Bearer
+ token and per-tool scope enforcement applies. Otherwise this mode performs
+ no request authentication -- deploy behind a reverse proxy or trusted
+ network."""
try:
from mcp.server.streamable_http import StreamableHTTPServerTransport
except ImportError:
@@ -283,7 +383,12 @@
from starlette.routing import Mount
transport = StreamableHTTPServerTransport(mcp_session_id=None)
- starlette_app = Starlette(routes=[Mount("/mcp", app=transport.handle_request)])
+ from starlette.middleware import Middleware
+
+ starlette_app = Starlette(
+ routes=[Mount("/mcp", app=transport.handle_request)],
+ middleware=[Middleware(_ScopeAuthMiddleware)],
+ )
config = uvicorn.Config(starlette_app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)