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())