Root cause
Authentication & access control introduced
feat: OAuth 2.1 Dynamic Client Registration for Claude Code HTTP Transport (#117)
How AI contributed: Direct introductionPublished Jun 26, 2026 in
doobidoo/mcp-memory-service
The AI-written OAuth 2.1 layer for the MCP endpoint gated /mcp behind a read scope and then dispatched store_memory and delete_memory straight to the memory backend without requiring a write grant. A client holding a read-only token could therefore create and delete stored memories, contradicting the read-only permission the token was issued under. The fix classifies both tools as writes and rejects their dispatch with HTTP 403 and JSON-RPC error -32003 unless the token carries write scope.
In src/mcp_memory_service/web/api/mcp.py the request path enforced require_read_access once for the endpoint and then handed every tool call, including store_memory and delete_memory, to the dispatcher with no per-tool scope check.
Root cause
feat: OAuth 2.1 Dynamic Client Registration for Claude Code HTTP Transport (#117)
Fix
The patch classifies store_memory and delete_memory as write tools and rejects their MCP dispatch with HTTP 403 / JSON-RPC -32003 unless the OAuth token has write scope.
Case facts
@@ -1,90 +0,0 @@-#!/usr/bin/env python3-"""Test Cloudflare connection for MCP Memory Service"""--import os-import sys-import requests-from pathlib import Path-from dotenv import load_dotenv--# Add the project directory to path-project_dir = Path(__file__).parent-sys.path.insert(0, str(project_dir / "src"))--# Load environment from .env file-load_dotenv()--# Now test the configuration-print("=== Cloudflare Configuration Test ===")-print(f"Backend: {os.getenv('MCP_MEMORY_STORAGE_BACKEND')}")-print(f"API Token: {os.getenv('CLOUDFLARE_API_TOKEN')[:10]}..." if os.getenv('CLOUDFLARE_API_TOKEN') else "API Token: NOT SET")-print(f"Account ID: {os.getenv('CLOUDFLARE_ACCOUNT_ID')}")-print(f"D1 Database ID: {os.getenv('CLOUDFLARE_D1_DATABASE_ID')}")-print(f"Vectorize Index: {os.getenv('CLOUDFLARE_VECTORIZE_INDEX')}")--# Test Cloudflare API connection-api_token = os.getenv('CLOUDFLARE_API_TOKEN')-account_id = os.getenv('CLOUDFLARE_ACCOUNT_ID')--if api_token and account_id:- print("\n=== Testing Cloudflare API Connection ===")- headers = {- 'Authorization': f'Bearer {api_token}',- 'Content-Type': 'application/json'- }- - # Test API token validity- url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}"- try:- response = requests.get(url, headers=headers)- if response.status_code == 200:- print("✅ Cloudflare API token is valid")- else:- print(f"❌ Cloudflare API error: {response.status_code}")- print(f"Response: {response.text}")- except Exception as e:- print(f"❌ Connection error: {str(e)}")--# Try to import and test the storage backend-print("\n=== Testing Storage Backend Import ===")-try:- from mcp_memory_service.config import STORAGE_BACKEND- print(f"Config says backend is: {STORAGE_BACKEND}")- - if STORAGE_BACKEND == 'cloudflare':- try:- from mcp_memory_service.storage.cloudflare import CloudflareStorage- print("✅ CloudflareStorage import successful")-- # Try to initialize- print("\n=== Attempting to Initialize Cloudflare Storage ===")- from mcp_memory_service.config import (- CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID,- CLOUDFLARE_VECTORIZE_INDEX, CLOUDFLARE_D1_DATABASE_ID,- CLOUDFLARE_R2_BUCKET, CLOUDFLARE_EMBEDDING_MODEL,- CLOUDFLARE_LARGE_CONTENT_THRESHOLD, CLOUDFLARE_MAX_RETRIES,- CLOUDFLARE_BASE_DELAY- )- storage = CloudflareStorage(- api_token=CLOUDFLARE_API_TOKEN,- account_id=CLOUDFLARE_ACCOUNT_ID,- vectorize_index=CLOUDFLARE_VECTORIZE_INDEX,- d1_database_id=CLOUDFLARE_D1_DATABASE_ID,- r2_bucket=CLOUDFLARE_R2_BUCKET,- embedding_model=CLOUDFLARE_EMBEDDING_MODEL,- large_content_threshold=CLOUDFLARE_LARGE_CONTENT_THRESHOLD,- max_retries=CLOUDFLARE_MAX_RETRIES,- base_delay=CLOUDFLARE_BASE_DELAY- )- print("✅ CloudflareStorage initialized")- except Exception as e:- print(f"❌ CloudflareMemoryStorage error: {str(e)}")- import traceback- traceback.print_exc()- else:- print(f"⚠️ Backend is set to {STORAGE_BACKEND}, not cloudflare")- -except Exception as e:- print(f"❌ Import error: {str(e)}")- import traceback@@ -0,0 +1,178 @@+# Copyright 2024 Heinrich Krupp+#+# Licensed under the Apache License, Version 2.0 (the "License");+# you may not use this file except in compliance with the License.+# You may obtain a copy of the License at+#+# http://www.apache.org/licenses/LICENSE-2.0+#+# Unless required by applicable law or agreed to in writing, software+# distributed under the License is distributed on an "AS IS" BASIS,+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.+# See the License for the specific language governing permissions and+# limitations under the License.++"""+OAuth 2.1 in-memory storage for MCP Memory Service.++Provides simple in-memory storage for OAuth clients and authorization codes.+This is an MVP implementation - production deployments should use persistent storage.+"""++import time+import secrets+import asyncio+from typing import Dict, Optional+from .models import RegisteredClient+from ...config import OAUTH_ACCESS_TOKEN_EXPIRE_MINUTES, OAUTH_AUTHORIZATION_CODE_EXPIRE_MINUTES+++class OAuthStorage:+ """In-memory storage for OAuth 2.1 clients and authorization codes."""++ def __init__(self):+ # Registered OAuth clients+ self._clients: Dict[str, RegisteredClient] = {}++ # Active authorization codes (code -> client_id, expires_at, redirect_uri, scope)+ self._authorization_codes: Dict[str, Dict] = {}++ # Active access tokens (token -> client_id, expires_at, scope)+ self._access_tokens: Dict[str, Dict] = {}++ # Thread safety lock for concurrent access+ self._lock = asyncio.Lock()++ async def store_client(self, client: RegisteredClient) -> None:+ """Store a registered OAuth client."""+ async with self._lock:+ self._clients[client.client_id] = client++ async def get_client(self, client_id: str) -> Optional[RegisteredClient]:+ """Get a registered OAuth client by ID."""+ async with self._lock:+ return self._clients.get(client_id)++ async def authenticate_client(self, client_id: str, client_secret: str) -> bool:+ """Authenticate a client using client_id and client_secret."""+ client = await self.get_client(client_id)+ if not client:+ return False+ return client.client_secret == client_secret++ async def store_authorization_code(+ self,+ code: str,+ client_id: str,+ redirect_uri: Optional[str] = None,+ scope: Optional[str] = None,+ expires_in: Optional[int] = None+ ) -> None:+ """Store an authorization code."""+ if expires_in is None:+ expires_in = OAUTH_AUTHORIZATION_CODE_EXPIRE_MINUTES * 60+ async with self._lock:+ self._authorization_codes[code] = {+ "client_id": client_id,+ "redirect_uri": redirect_uri,+ "scope": scope,+ "expires_at": time.time() + expires_in+ }++ async def get_authorization_code(self, code: str) -> Optional[Dict]:+ """Get and consume an authorization code (one-time use)."""+ async with self._lock:+ code_data = self._authorization_codes.pop(code, None)++ # Check if code exists and hasn't expired+ if code_data and code_data["expires_at"] > time.time():+ return code_data@@ -0,0 +1,307 @@+# Copyright 2024 Heinrich Krupp+#+# Licensed under the Apache License, Version 2.0 (the "License");+# you may not use this file except in compliance with the License.+# You may obtain a copy of the License at+#+# http://www.apache.org/licenses/LICENSE-2.0+#+# Unless required by applicable law or agreed to in writing, software+# distributed under the License is distributed on an "AS IS" BASIS,+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.+# See the License for the specific language governing permissions and+# limitations under the License.++"""+OAuth 2.1 Dynamic Client Registration implementation for MCP Memory Service.++Implements RFC 7591 - OAuth 2.0 Dynamic Client Registration Protocol.+"""++import time+import logging+from typing import List, Optional+from urllib.parse import urlparse, ParseResult+from fastapi import APIRouter, HTTPException, status+from pydantic import ValidationError++from .models import (+ ClientRegistrationRequest,+ ClientRegistrationResponse,+ RegisteredClient+)+from .storage import oauth_storage++logger = logging.getLogger(__name__)++router = APIRouter()+++def validate_redirect_uris(redirect_uris: Optional[List[str]]) -> None:+ """+ Validate redirect URIs according to OAuth 2.1 security requirements.++ Uses proper URL parsing to prevent bypass attacks and validates schemes+ against a secure whitelist to prevent dangerous scheme injection.+ """+ if not redirect_uris:+ return++ # Allowed schemes - whitelist approach for security+ ALLOWED_SCHEMES = {+ 'https', # HTTPS (preferred)+ 'http', # HTTP (localhost only)+ # Native app custom schemes (common patterns)+ 'com.example.app', # Reverse domain notation+ 'myapp', # Simple custom scheme+ # Add more custom schemes as needed, but NEVER allow:+ # javascript:, data:, file:, vbscript:, about:, chrome:, etc.+ }++ # Dangerous schemes that must be blocked+ DANGEROUS_SCHEMES = {+ 'javascript', 'data', 'file', 'vbscript', 'about', 'chrome',+ 'chrome-extension', 'moz-extension', 'ms-appx', 'blob'+ }++ for uri in redirect_uris:+ uri_str = str(uri).strip()++ if not uri_str:+ raise HTTPException(+ status_code=status.HTTP_400_BAD_REQUEST,+ detail={+ "error": "invalid_redirect_uri",+ "error_description": "Empty redirect URI not allowed"+ }+ )++ try:+ # Parse URL using proper URL parser to prevent bypass attacks+ parsed: ParseResult = urlparse(uri_str)++ if not parsed.scheme:+ raise HTTPException(+ status_code=status.HTTP_400_BAD_REQUEST,+ detail={+ "error": "invalid_redirect_uri",+ "error_description": f"Missing scheme in redirect URI: {uri_str}"+ }diff --git a/src/mcp_memory_service/web/api/mcp.py b/src/mcp_memory_service/web/api/mcp.pyindex 15edc280..af8f1a14 100644--- a/src/mcp_memory_service/web/api/mcp.py+++ b/src/mcp_memory_service/web/api/mcp.py@@ -21,6 +21,11 @@ from ..oauth.middleware import require_read_access, AuthenticationResult logger = logging.getLogger(__name__) +# Tools that mutate state — require 'write' scope even through MCP.+# Read-only tools (retrieve_memory, recall_memory, search_by_tag,+# check_database_health, list_memories) remain accessible with 'read' scope.+_WRITE_TOOLS: frozenset = frozenset({"store_memory", "delete_memory"})+ router = APIRouter(prefix="/mcp", tags=["mcp"]) @@ -189,6 +194,24 @@ async def mcp_endpoint( tool_name = request.params.get("name") if request.params else None arguments = request.params.get("arguments", {}) if request.params else {} + # Scope enforcement: mutating tools require 'write' scope.+ # A read-only OAuth token must not reach store_memory or+ # delete_memory — the REST layer already enforces this boundary;+ # MCP must match it (GHSA-2r68-g678-7qr3).+ if tool_name in _WRITE_TOOLS and not user.has_scope("write"):+ response = MCPResponse(+ id=request.id,+ error={+ "code": -32003,+ "message": "Insufficient scope: tool requires 'write' access",+ "data": {"required_scope": "write", "tool": tool_name}+ }+ )+ return JSONResponse(+ content=response.model_dump(exclude_none=True),+ status_code=403+ )+ result = await handle_tool_call(storage, tool_name, arguments) response = MCPResponse(AI-assisted change 6d296eaff0c7891e23e3d96ada9917a88fa8ee88cb60b4434ef16468157039b4 · Fix a69bc393399547c714e8ecea3fdf88752a1491011e2cdac53c7a2d2140341a88