From 7b75304c9f1b50d19533277ce160a9acb4bdccf4 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 24 Jan 2026 09:26:30 +0100 Subject: [PATCH] feat(scripts): add database query helpers for development Add dbquery.py for MariaDB and sqlitequery.py for SQLite databases in MCP service containers. Both scripts wrap docker compose exec to simplify database inspection during development. - dbquery.py: Query Nextcloud MariaDB with vertical/JSON output - sqlitequery.py: Query MCP service SQLite DBs with service aliases (mcp, oauth, keycloak, basic) and column/JSON output modes - Document both scripts in CLAUDE.md Database Inspection section Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 53 ++++++++++++ scripts/dbquery.py | 145 +++++++++++++++++++++++++++++++++ scripts/sqlitequery.py | 177 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 375 insertions(+) create mode 100755 scripts/dbquery.py create mode 100755 scripts/sqlitequery.py diff --git a/CLAUDE.md b/CLAUDE.md index a2441f7..343678d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -239,6 +239,25 @@ uv run python -m tests.load.benchmark --output results.json --verbose **Credentials**: root/password, nextcloud/password, database: `nextcloud` +### Quick Query Script (Recommended for Agents) + +Use `scripts/dbquery.py` for single SQL statements without requiring approval for each `docker compose exec`: + +```bash +# Basic query +./scripts/dbquery.py "SELECT COUNT(*) FROM oc_users" + +# Vertical output (one column per line) - useful for wide tables +./scripts/dbquery.py -E "SELECT * FROM oc_oidc_clients LIMIT 1" + +# With different credentials +./scripts/dbquery.py -u nextcloud -p nextcloud "SHOW TABLES" +``` + +### Direct Docker Access + +For interactive sessions or complex operations: + ```bash # Connect to database docker compose exec db mariadb -u root -ppassword nextcloud @@ -264,6 +283,40 @@ docker compose exec db mariadb -u root -ppassword nextcloud -e \ - `oc_oidc_registration_tokens` - RFC 7592 registration tokens - `oc_oidc_redirect_uris` - Redirect URIs +### SQLite Databases (MCP Services) + +Use `scripts/sqlitequery.py` to query SQLite databases in MCP service containers: + +```bash +# List tables +./scripts/sqlitequery.py ".tables" + +# Query specific service +./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens" +./scripts/sqlitequery.py -s keycloak "SELECT * FROM oauth_clients" +./scripts/sqlitequery.py -s basic "SELECT * FROM app_passwords" + +# With column headers +./scripts/sqlitequery.py --column "SELECT * FROM audit_logs LIMIT 5" + +# JSON output +./scripts/sqlitequery.py --json "SELECT * FROM oauth_sessions" + +# View schema +./scripts/sqlitequery.py -s oauth ".schema refresh_tokens" +``` + +**Services**: `mcp` (default), `oauth`, `keycloak`, `basic` + +**SQLite Tables**: +- `refresh_tokens` - OAuth refresh tokens with user profiles +- `audit_logs` - Security audit trail +- `oauth_clients` - DCR OAuth client credentials +- `oauth_sessions` - OAuth flow session state +- `registered_webhooks` - Webhook registrations +- `app_passwords` - Multi-user BasicAuth passwords +- `alembic_version` - Migration tracking + ## Architecture Quick Reference **For detailed architecture, see:** diff --git a/scripts/dbquery.py b/scripts/dbquery.py new file mode 100755 index 0000000..21e894d --- /dev/null +++ b/scripts/dbquery.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +Database query helper for development. + +Wraps `docker compose exec db mariadb` to execute SQL statements against +the Nextcloud MariaDB database. + +Usage: + ./scripts/dbquery.py "SELECT * FROM oc_notes LIMIT 5" + ./scripts/dbquery.py -u root -p password "SHOW TABLES" + ./scripts/dbquery.py --json "SELECT * FROM oc_oidc_clients" +""" + +import argparse +import subprocess +import sys +from pathlib import Path + + +def find_compose_dir() -> Path: + """Find the directory containing docker-compose.yml.""" + current = Path(__file__).resolve().parent + while current != current.parent: + if (current / "docker-compose.yml").exists(): + return current + if (current / "compose.yml").exists(): + return current + current = current.parent + # Default to script's parent directory + return Path(__file__).resolve().parent.parent + + +def run_query( + sql: str, + user: str = "root", + password: str = "password", + database: str = "nextcloud", + vertical: bool = False, + json_output: bool = False, +) -> tuple[int, str, str]: + """ + Execute SQL via docker compose exec. + + Returns: + Tuple of (return_code, stdout, stderr) + """ + compose_dir = find_compose_dir() + + cmd = [ + "docker", + "compose", + "exec", + "-T", # Disable pseudo-TTY allocation + "db", + "mariadb", + f"-u{user}", + f"-p{password}", + database, + "-e", + sql, + ] + + if vertical: + cmd.insert(-2, "-E") # Vertical output format + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=compose_dir, + ) + + return result.returncode, result.stdout, result.stderr + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Execute SQL queries against the Nextcloud MariaDB database", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s "SELECT COUNT(*) FROM oc_notes" + %(prog)s "SELECT id, name FROM oc_oidc_clients" + %(prog)s -E "SELECT * FROM oc_users LIMIT 1" + %(prog)s --user nextcloud --password nextcloud "SHOW TABLES" + """, + ) + parser.add_argument("sql", help="SQL statement to execute") + parser.add_argument( + "-u", "--user", default="root", help="Database user (default: root)" + ) + parser.add_argument( + "-p", + "--password", + default="password", + help="Database password (default: password)", + ) + parser.add_argument( + "-d", + "--database", + default="nextcloud", + help="Database name (default: nextcloud)", + ) + parser.add_argument( + "-E", + "--vertical", + action="store_true", + help="Print output vertically (one column per line)", + ) + parser.add_argument( + "--json", + action="store_true", + dest="json_output", + help="Request JSON output (if supported)", + ) + + args = parser.parse_args() + + returncode, stdout, stderr = run_query( + sql=args.sql, + user=args.user, + password=args.password, + database=args.database, + vertical=args.vertical, + json_output=args.json_output, + ) + + if stdout: + print(stdout, end="") + if stderr: + # Filter out the password warning + filtered_stderr = "\n".join( + line + for line in stderr.splitlines() + if "Using a password on the command line interface can be insecure" + not in line + ) + if filtered_stderr: + print(filtered_stderr, file=sys.stderr) + + return returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sqlitequery.py b/scripts/sqlitequery.py new file mode 100755 index 0000000..a4292c6 --- /dev/null +++ b/scripts/sqlitequery.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +SQLite database query helper for MCP service development. + +Wraps `docker compose exec sqlite3` to execute SQL statements +against the token storage database in any MCP service container. + +Usage: + ./scripts/sqlitequery.py ".tables" + ./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens" + ./scripts/sqlitequery.py -s keycloak --headers "SELECT * FROM oauth_clients" + ./scripts/sqlitequery.py --json "SELECT * FROM audit_logs LIMIT 5" +""" + +import argparse +import subprocess +import sys +from pathlib import Path + +# Service name aliases for convenience +SERVICE_ALIASES = { + "mcp": "mcp", + "oauth": "mcp-oauth", + "mcp-oauth": "mcp-oauth", + "keycloak": "mcp-keycloak", + "mcp-keycloak": "mcp-keycloak", + "basic": "mcp-multi-user-basic", + "multi-user-basic": "mcp-multi-user-basic", + "mcp-multi-user-basic": "mcp-multi-user-basic", +} + + +def find_compose_dir() -> Path: + """Find the directory containing docker-compose.yml.""" + current = Path(__file__).resolve().parent + while current != current.parent: + if (current / "docker-compose.yml").exists(): + return current + if (current / "compose.yml").exists(): + return current + current = current.parent + # Default to script's parent directory + return Path(__file__).resolve().parent.parent + + +def resolve_service(service: str) -> str: + """Resolve service alias to container name.""" + resolved = SERVICE_ALIASES.get(service.lower()) + if resolved is None: + # Not a known alias, use as-is (might be a custom service) + return service + return resolved + + +def run_query( + sql: str, + service: str = "mcp", + database: str = "/app/data/tokens.db", + headers: bool = False, + json_output: bool = False, + column_mode: bool = False, +) -> tuple[int, str, str]: + """ + Execute SQL via docker compose exec. + + Returns: + Tuple of (return_code, stdout, stderr) + """ + compose_dir = find_compose_dir() + container = resolve_service(service) + + # Build sqlite3 command with options + sqlite_args = [] + + # Set output mode + if json_output: + sqlite_args.extend(["-json"]) + elif column_mode: + sqlite_args.extend(["-column"]) + + # Enable headers + if headers or column_mode: + sqlite_args.extend(["-header"]) + + cmd = [ + "docker", + "compose", + "exec", + "-T", # Disable pseudo-TTY allocation + container, + "sqlite3", + *sqlite_args, + database, + sql, + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=compose_dir, + ) + + return result.returncode, result.stdout, result.stderr + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Execute SQL queries against SQLite databases in MCP service containers", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Services: + mcp Single-user BasicAuth mode (default) + oauth Nextcloud OAuth mode (mcp-oauth) + keycloak Keycloak OAuth mode (mcp-keycloak) + basic Multi-user BasicAuth mode (mcp-multi-user-basic) + +Examples: + %(prog)s ".tables" + %(prog)s -s oauth "SELECT user_id FROM refresh_tokens" + %(prog)s -s keycloak ".schema oauth_clients" + %(prog)s --headers "SELECT * FROM audit_logs LIMIT 5" + %(prog)s --json "SELECT * FROM oauth_sessions" + """, + ) + parser.add_argument("sql", help="SQL statement or SQLite command to execute") + parser.add_argument( + "-s", + "--service", + default="mcp", + help="Target service (mcp, oauth, keycloak, basic) (default: mcp)", + ) + parser.add_argument( + "-d", + "--database", + default="/app/data/tokens.db", + help="Database path inside container (default: /app/data/tokens.db)", + ) + parser.add_argument( + "--headers", + action="store_true", + help="Show column headers", + ) + parser.add_argument( + "--json", + action="store_true", + dest="json_output", + help="Output in JSON format", + ) + parser.add_argument( + "--column", + action="store_true", + dest="column_mode", + help="Output in column format with headers", + ) + + args = parser.parse_args() + + returncode, stdout, stderr = run_query( + sql=args.sql, + service=args.service, + database=args.database, + headers=args.headers, + json_output=args.json_output, + column_mode=args.column_mode, + ) + + if stdout: + print(stdout, end="") + if stderr: + print(stderr, file=sys.stderr) + + return returncode + + +if __name__ == "__main__": + sys.exit(main())