|
| 1 | +""" |
| 2 | +Test Neo4j Aura MCP server OAuth flows and output Databricks configuration. |
| 3 | +
|
| 4 | +Flow: |
| 5 | +1. Register an OAuth client via Auth0 dynamic registration |
| 6 | +2. Run OAuth Authorization Code + PKCE flow (opens browser) |
| 7 | +3. Test the MCP endpoint with the user token |
| 8 | +4. Print Databricks configuration values |
| 9 | +
|
| 10 | +Key finding: The audience parameter must be OMITTED from the authorization |
| 11 | +request. Auth0 then defaults to audience=https://console.neo4j.io, which |
| 12 | +is what the MCP server validates against. |
| 13 | +""" |
| 14 | + |
| 15 | +import base64 |
| 16 | +import hashlib |
| 17 | +import http.server |
| 18 | +import json |
| 19 | +import os |
| 20 | +import secrets |
| 21 | +import sys |
| 22 | +import urllib.error |
| 23 | +import urllib.parse |
| 24 | +import urllib.request |
| 25 | +import webbrowser |
| 26 | + |
| 27 | +# OAuth endpoints (discovered from https://mcp.neo4j.io/.well-known/oauth-authorization-server) |
| 28 | +AUTH0_ISSUER = "https://aura-mcp.eu.auth0.com" |
| 29 | +AUTHORIZATION_ENDPOINT = f"{AUTH0_ISSUER}/authorize" |
| 30 | +TOKEN_ENDPOINT = f"{AUTH0_ISSUER}/oauth/token" |
| 31 | +REGISTRATION_ENDPOINT = f"{AUTH0_ISSUER}/oidc/register" |
| 32 | +SCOPES = "openid profile email" |
| 33 | + |
| 34 | +LOCAL_PORT = 8976 |
| 35 | +REDIRECT_URI = f"http://localhost:{LOCAL_PORT}/callback" |
| 36 | + |
| 37 | + |
| 38 | +def load_env(path): |
| 39 | + """Load .env file into a dict.""" |
| 40 | + env = {} |
| 41 | + with open(path) as f: |
| 42 | + for line in f: |
| 43 | + line = line.strip() |
| 44 | + if not line or line.startswith("#"): |
| 45 | + continue |
| 46 | + key, _, value = line.partition("=") |
| 47 | + env[key.strip()] = value.strip().strip('"') |
| 48 | + return env |
| 49 | + |
| 50 | + |
| 51 | +def register_oauth_client(redirect_uri=None): |
| 52 | + """Register a client via Auth0 dynamic client registration.""" |
| 53 | + uri = redirect_uri or REDIRECT_URI |
| 54 | + payload = json.dumps({ |
| 55 | + "client_name": "neo4j-mcp-databricks-test", |
| 56 | + "redirect_uris": [uri], |
| 57 | + "grant_types": ["authorization_code"], |
| 58 | + "response_types": ["code"], |
| 59 | + "token_endpoint_auth_method": "none", |
| 60 | + }).encode() |
| 61 | + |
| 62 | + req = urllib.request.Request( |
| 63 | + REGISTRATION_ENDPOINT, |
| 64 | + data=payload, |
| 65 | + headers={"Content-Type": "application/json"}, |
| 66 | + method="POST", |
| 67 | + ) |
| 68 | + |
| 69 | + print("1. Registering OAuth client via dynamic registration ...") |
| 70 | + print(f" Redirect URI: {uri}") |
| 71 | + try: |
| 72 | + with urllib.request.urlopen(req) as resp: |
| 73 | + body = json.loads(resp.read()) |
| 74 | + client_id = body["client_id"] |
| 75 | + client_secret = body.get("client_secret", "") |
| 76 | + print(f" SUCCESS") |
| 77 | + print(f" Client ID: {client_id}") |
| 78 | + if client_secret: |
| 79 | + print(f" Client Secret: {client_secret}") |
| 80 | + return client_id, client_secret |
| 81 | + except urllib.error.HTTPError as e: |
| 82 | + print(f" FAILED - {e.code} {e.reason}") |
| 83 | + print(f" {e.read().decode()}") |
| 84 | + return None, None |
| 85 | + |
| 86 | + |
| 87 | +def generate_pkce(): |
| 88 | + """Generate PKCE code_verifier and code_challenge.""" |
| 89 | + verifier = secrets.token_urlsafe(43) |
| 90 | + challenge = base64.urlsafe_b64encode( |
| 91 | + hashlib.sha256(verifier.encode()).digest() |
| 92 | + ).rstrip(b"=").decode() |
| 93 | + return verifier, challenge |
| 94 | + |
| 95 | + |
| 96 | +def do_oauth_flow(client_id): |
| 97 | + """Run OAuth Authorization Code + PKCE flow with local callback server. |
| 98 | +
|
| 99 | + IMPORTANT: Do NOT set the 'audience' parameter. Auth0 defaults to |
| 100 | + audience=https://console.neo4j.io, which the MCP server requires. |
| 101 | + Setting audience explicitly (e.g. to Auth0's /api/v2/) produces a |
| 102 | + token the MCP server rejects with 401. |
| 103 | + """ |
| 104 | + code_verifier, code_challenge = generate_pkce() |
| 105 | + state = secrets.token_urlsafe(16) |
| 106 | + |
| 107 | + # Build authorization URL — NO audience parameter |
| 108 | + auth_params = urllib.parse.urlencode({ |
| 109 | + "client_id": client_id, |
| 110 | + "redirect_uri": REDIRECT_URI, |
| 111 | + "response_type": "code", |
| 112 | + "scope": SCOPES, |
| 113 | + "state": state, |
| 114 | + "code_challenge": code_challenge, |
| 115 | + "code_challenge_method": "S256", |
| 116 | + # audience is intentionally omitted — Auth0 defaults to |
| 117 | + # https://console.neo4j.io which the MCP server expects |
| 118 | + }) |
| 119 | + auth_url = f"{AUTHORIZATION_ENDPOINT}?{auth_params}" |
| 120 | + |
| 121 | + # Capture the authorization code via local HTTP server |
| 122 | + result = {"code": None, "error": None} |
| 123 | + |
| 124 | + class CallbackHandler(http.server.BaseHTTPRequestHandler): |
| 125 | + def do_GET(self): |
| 126 | + parsed = urllib.parse.urlparse(self.path) |
| 127 | + params = urllib.parse.parse_qs(parsed.query) |
| 128 | + |
| 129 | + if "code" in params: |
| 130 | + result["code"] = params["code"][0] |
| 131 | + self.send_response(200) |
| 132 | + self.send_header("Content-Type", "text/html") |
| 133 | + self.end_headers() |
| 134 | + self.wfile.write( |
| 135 | + b"<h2>Authorization successful!</h2>" |
| 136 | + b"<p>You can close this tab and return to the terminal.</p>" |
| 137 | + ) |
| 138 | + else: |
| 139 | + result["error"] = params.get("error", ["unknown"])[0] |
| 140 | + self.send_response(400) |
| 141 | + self.send_header("Content-Type", "text/html") |
| 142 | + self.end_headers() |
| 143 | + self.wfile.write(f"<h2>Error: {result['error']}</h2>".encode()) |
| 144 | + |
| 145 | + def log_message(self, format, *args): |
| 146 | + pass # Suppress server logs |
| 147 | + |
| 148 | + server = http.server.HTTPServer(("localhost", LOCAL_PORT), CallbackHandler) |
| 149 | + |
| 150 | + print(f"\n2. Starting OAuth Authorization Code + PKCE flow ...") |
| 151 | + print(f" Opening browser for Neo4j Aura login ...") |
| 152 | + print(f" (Log in with your Aura Console credentials)\n") |
| 153 | + |
| 154 | + # Open browser in background |
| 155 | + webbrowser.open(auth_url) |
| 156 | + |
| 157 | + # Wait for callback (single request) |
| 158 | + server.handle_request() |
| 159 | + server.server_close() |
| 160 | + |
| 161 | + if result["error"]: |
| 162 | + print(f" FAILED - OAuth error: {result['error']}") |
| 163 | + return None |
| 164 | + |
| 165 | + if not result["code"]: |
| 166 | + print(f" FAILED - No authorization code received") |
| 167 | + return None |
| 168 | + |
| 169 | + print(f" Got authorization code, exchanging for token ...") |
| 170 | + |
| 171 | + # Exchange code for token |
| 172 | + token_data = urllib.parse.urlencode({ |
| 173 | + "grant_type": "authorization_code", |
| 174 | + "client_id": client_id, |
| 175 | + "code": result["code"], |
| 176 | + "redirect_uri": REDIRECT_URI, |
| 177 | + "code_verifier": code_verifier, |
| 178 | + }).encode() |
| 179 | + |
| 180 | + req = urllib.request.Request( |
| 181 | + TOKEN_ENDPOINT, |
| 182 | + data=token_data, |
| 183 | + headers={"Content-Type": "application/x-www-form-urlencoded"}, |
| 184 | + method="POST", |
| 185 | + ) |
| 186 | + |
| 187 | + try: |
| 188 | + with urllib.request.urlopen(req) as resp: |
| 189 | + body = json.loads(resp.read()) |
| 190 | + token = body["access_token"] |
| 191 | + expires = body.get("expires_in", "unknown") |
| 192 | + print(f" SUCCESS - got user token (expires in {expires}s)") |
| 193 | + print(f" Token prefix: {token[:30]}...") |
| 194 | + |
| 195 | + # Decode JWT to show audience (for debugging) |
| 196 | + if token.startswith("eyJ"): |
| 197 | + parts = token.split(".") |
| 198 | + payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4) |
| 199 | + decoded = json.loads(base64.urlsafe_b64decode(payload_b64)) |
| 200 | + print(f" Token audience: {decoded.get('aud')}") |
| 201 | + |
| 202 | + return token |
| 203 | + except urllib.error.HTTPError as e: |
| 204 | + print(f" FAILED - {e.code} {e.reason}") |
| 205 | + print(f" {e.read().decode()}") |
| 206 | + return None |
| 207 | + |
| 208 | + |
| 209 | +def test_mcp_endpoint(mcp_url, token, label="3"): |
| 210 | + """Send MCP initialize request to test auth.""" |
| 211 | + payload = json.dumps({ |
| 212 | + "jsonrpc": "2.0", |
| 213 | + "id": 1, |
| 214 | + "method": "initialize", |
| 215 | + "params": { |
| 216 | + "protocolVersion": "2025-03-26", |
| 217 | + "capabilities": {}, |
| 218 | + "clientInfo": {"name": "databricks-auth-test", "version": "1.0.0"}, |
| 219 | + }, |
| 220 | + }).encode() |
| 221 | + |
| 222 | + req = urllib.request.Request( |
| 223 | + mcp_url, |
| 224 | + data=payload, |
| 225 | + headers={ |
| 226 | + "Authorization": f"Bearer {token}", |
| 227 | + "Content-Type": "application/json", |
| 228 | + "Accept": "application/json, text/event-stream", |
| 229 | + }, |
| 230 | + method="POST", |
| 231 | + ) |
| 232 | + |
| 233 | + print(f"\n{label}. Testing MCP endpoint with user token ...") |
| 234 | + print(f" URL: {mcp_url}") |
| 235 | + try: |
| 236 | + with urllib.request.urlopen(req, timeout=30) as resp: |
| 237 | + body = resp.read().decode() |
| 238 | + print(f" SUCCESS - HTTP {resp.status}") |
| 239 | + print(f" Response: {body[:500]}") |
| 240 | + return True |
| 241 | + except urllib.error.HTTPError as e: |
| 242 | + body = e.read().decode() |
| 243 | + print(f" FAILED - HTTP {e.code} {e.reason}") |
| 244 | + print(f" Response: {body[:500]}") |
| 245 | + return False |
| 246 | + |
| 247 | + |
| 248 | +def print_databricks_config(client_id, client_secret, mcp_url): |
| 249 | + """Print the values to enter in Databricks.""" |
| 250 | + print("\n" + "=" * 60) |
| 251 | + print("DATABRICKS CONFIGURATION") |
| 252 | + print("=" * 60) |
| 253 | + print() |
| 254 | + print("Step 1 - Connection basics:") |
| 255 | + print(f" Connection name: finance_mcp_server") |
| 256 | + print(f" Connection type: HTTP") |
| 257 | + print(f" Auth type: OAuth User to Machine Per User") |
| 258 | + print(f" OAuth provider: Manual configuration") |
| 259 | + print() |
| 260 | + print("Step 2 - Authentication:") |
| 261 | + print(f" Host: https://mcp.neo4j.io") |
| 262 | + print(f" Port: 443") |
| 263 | + print(f" OAuth scope: {SCOPES}") |
| 264 | + print(f" Client ID: {client_id}") |
| 265 | + print(f" Client secret: {client_secret or '(leave empty - public client)'}") |
| 266 | + print(f" Authorization endpoint: {AUTHORIZATION_ENDPOINT}") |
| 267 | + print(f" Token endpoint: {TOKEN_ENDPOINT}") |
| 268 | + print() |
| 269 | + print("IMPORTANT: Do NOT set an OAuth audience parameter.") |
| 270 | + print("Auth0 must default to audience=https://console.neo4j.io") |
| 271 | + print("for the MCP server to accept the token.") |
| 272 | + print() |
| 273 | + print("MCP Server URL (for Databricks MCP tool config):") |
| 274 | + print(f" {mcp_url}") |
| 275 | + print() |
| 276 | + print("NOTE: The client_id above was registered with redirect_uri") |
| 277 | + print(f" {REDIRECT_URI}") |
| 278 | + print("If Databricks uses a different redirect_uri, re-register via:") |
| 279 | + print(f" POST {REGISTRATION_ENDPOINT}") |
| 280 | + print(f" Body: {{\"client_name\":\"databricks\",\"redirect_uris\":[\"<DATABRICKS_URI>\"],") |
| 281 | + print(f" \"grant_types\":[\"authorization_code\"],\"response_types\":[\"code\"],") |
| 282 | + print(f" \"token_endpoint_auth_method\":\"none\"}}") |
| 283 | + print() |
| 284 | + |
| 285 | + |
| 286 | +def main(): |
| 287 | + env_path = os.path.join(os.path.dirname(__file__), "financial_data_load", ".env") |
| 288 | + print(f"Loading credentials from {env_path}\n") |
| 289 | + env = load_env(env_path) |
| 290 | + |
| 291 | + mcp_url = env.get("AURA_AGENT_MCP_SERVER") |
| 292 | + if not mcp_url: |
| 293 | + print("ERROR: Missing AURA_AGENT_MCP_SERVER in .env") |
| 294 | + sys.exit(1) |
| 295 | + |
| 296 | + # Step 1: Register OAuth client |
| 297 | + client_id, client_secret = register_oauth_client() |
| 298 | + if not client_id: |
| 299 | + print("\nCannot proceed without a client ID.") |
| 300 | + sys.exit(1) |
| 301 | + |
| 302 | + # Step 2: OAuth flow (opens browser) |
| 303 | + token = do_oauth_flow(client_id) |
| 304 | + if not token: |
| 305 | + print("\nCannot proceed without a token.") |
| 306 | + sys.exit(1) |
| 307 | + |
| 308 | + # Step 3: Test MCP endpoint |
| 309 | + mcp_ok = test_mcp_endpoint(mcp_url, token) |
| 310 | + |
| 311 | + # Print Databricks config |
| 312 | + print_databricks_config(client_id, client_secret, mcp_url) |
| 313 | + |
| 314 | + # Summary |
| 315 | + print("=" * 60) |
| 316 | + print("RESULT") |
| 317 | + print("=" * 60) |
| 318 | + if mcp_ok: |
| 319 | + print(" User OAuth token WORKS with MCP endpoint!") |
| 320 | + print(" OAuth User to Machine Per User should work in Databricks.") |
| 321 | + else: |
| 322 | + print(" User OAuth token FAILED with MCP endpoint.") |
| 323 | + print(" Further investigation needed.") |
| 324 | + |
| 325 | + |
| 326 | +if __name__ == "__main__": |
| 327 | + main() |
0 commit comments