From ae3526c5d11f6cf188e8b407eab235cc77d50dfe Mon Sep 17 00:00:00 2001 From: PRAMOD B N Date: Tue, 20 Oct 2026 02:20:16 -0500 Subject: [PATCH 1/7] test(transport): stop remote-mode flag leaking into later tests --- tests/test_transport_http.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_transport_http.py b/tests/test_transport_http.py index c6bace2..790a6f8 100644 --- a/tests/test_transport_http.py +++ b/tests/test_transport_http.py @@ -14,11 +14,10 @@ @pytest.fixture(autouse=True) def remote_environment(monkeypatch): - monkeypatch.delenv(config.REMOTE_TRANSPORT_ENV, raising=False) + # setenv records the prior state, so teardown also undoes create_app's flag. + monkeypatch.setenv(config.REMOTE_TRANSPORT_ENV, "0") monkeypatch.delenv("DEEP_AGENTIC_CORE_MCP_API_KEYS", raising=False) monkeypatch.delenv("DEEP_AGENTIC_CORE_MCP_REDIS_URL", raising=False) - yield - monkeypatch.delenv(config.REMOTE_TRANSPORT_ENV, raising=False) def app_for_test(**kwargs): From 31caa08439007ebaed92cd6ed7fff45b3f9fc0e6 Mon Sep 17 00:00:00 2001 From: PRAMOD B N Date: Tue, 20 Oct 2026 02:20:16 -0500 Subject: [PATCH 2/7] refactor(auth): raise backend-neutral KeyConflictError for key conflicts --- .../services/api_keys.py | 30 ++++++++++++++++++- src/deep_agentic_core_mcp/signup.py | 8 ++--- tests/test_signup.py | 8 ++--- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/deep_agentic_core_mcp/services/api_keys.py b/src/deep_agentic_core_mcp/services/api_keys.py index cdf977d..2bfa449 100644 --- a/src/deep_agentic_core_mcp/services/api_keys.py +++ b/src/deep_agentic_core_mcp/services/api_keys.py @@ -14,12 +14,28 @@ def key_digest(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() +class KeyConflictError(Exception): + """The account changed concurrently, or the presented key is no longer current.""" + + class KeyStore(Protocol): def authenticate(self, token: str) -> str | None: ... def ping(self) -> bool: ... +class AccountStore(KeyStore, Protocol): + """A key store that can also serve self-service signup and key management.""" + + def issue(self, display_name: str) -> dict[str, str]: ... + + def profile(self, user: str) -> dict[str, str]: ... + + def rotate(self, user: str, old_token: str) -> dict[str, str]: ... + + def revoke(self, user: str, token: str) -> None: ... + + class StaticKeyStore: """Explicit local/development configuration, compatible with existing installs.""" @@ -143,6 +159,13 @@ def rotate(self, user: str, old_token: str) -> dict[str, str]: old_hash, new_hash = key_digest(old_token), key_digest(token) # Conditional transaction prevents concurrent rotations/revocations from # leaving a second usable key or resurrecting a revoked account. + try: + self._rotate(user, old_hash, new_hash) + except self.client.exceptions.TransactionCanceledException as exc: + raise KeyConflictError("Account changed") from exc + return {"user_id": user, "api_key": token} + + def _rotate(self, user: str, old_hash: str, new_hash: str) -> None: self.client.transact_write_items( TransactItems=[ { @@ -169,9 +192,14 @@ def rotate(self, user: str, old_token: str) -> dict[str, str]: }, ] ) - return {"user_id": user, "api_key": token} def revoke(self, user: str, token: str) -> None: + try: + self._revoke(user, token) + except self.client.exceptions.ConditionalCheckFailedException as exc: + raise KeyConflictError("Account changed") from exc + + def _revoke(self, user: str, token: str) -> None: self.client.update_item( TableName=self.table_name, Key={"pk": "USER#" + user}, diff --git a/src/deep_agentic_core_mcp/signup.py b/src/deep_agentic_core_mcp/signup.py index 4f55c96..b06a62c 100644 --- a/src/deep_agentic_core_mcp/signup.py +++ b/src/deep_agentic_core_mcp/signup.py @@ -12,7 +12,7 @@ from starlette.responses import Response from starlette.routing import Route -from deep_agentic_core_mcp.services.api_keys import DynamoKeyStore +from deep_agentic_core_mcp.services.api_keys import AccountStore, KeyConflictError class SignupLimiter(Protocol): @@ -44,7 +44,7 @@ def allow(self, peer: str) -> bool: def signup_routes( - keys: DynamoKeyStore, + keys: AccountStore, limiter: SignupLimiter, public_url: str, *, @@ -134,9 +134,7 @@ async def account(request: Request) -> Response: result = {"status": "revoked"} else: result = await asyncio.to_thread(keys.profile, user) - except keys.client.exceptions.TransactionCanceledException: - return reply({"error": "Account changed. Retry with your current key."}, 409) - except keys.client.exceptions.ConditionalCheckFailedException: + except KeyConflictError: return reply({"error": "Account changed. Retry with your current key."}, 409) except Exception: return reply({"error": "Account temporarily unavailable"}, 503) diff --git a/tests/test_signup.py b/tests/test_signup.py index af780fd..de484da 100644 --- a/tests/test_signup.py +++ b/tests/test_signup.py @@ -9,7 +9,7 @@ from moto import mock_aws from starlette.applications import Starlette -from deep_agentic_core_mcp.services.api_keys import DynamoKeyStore +from deep_agentic_core_mcp.services.api_keys import DynamoKeyStore, KeyConflictError from deep_agentic_core_mcp.services.remote_sessions import RedisSessionStore from deep_agentic_core_mcp.signup import RedisSignupLimiter, signup_routes from deep_agentic_core_mcp.transport_http import create_app @@ -39,12 +39,12 @@ def test_hashes_only_rotation_revocation_and_stable_identity(backend): assert rotated["user_id"] == first["user_id"] assert backend.authenticate(first["api_key"]) is None assert backend.authenticate(rotated["api_key"]) == first["user_id"] - with pytest.raises(backend.client.exceptions.TransactionCanceledException): + with pytest.raises(KeyConflictError): backend.rotate(first["user_id"], first["api_key"]) backend.revoke(first["user_id"], rotated["api_key"]) assert backend.authenticate(rotated["api_key"]) is None assert backend.authenticate(second["api_key"]) == second["user_id"] - with pytest.raises(backend.client.exceptions.TransactionCanceledException): + with pytest.raises(KeyConflictError): backend.rotate(first["user_id"], rotated["api_key"]) @@ -192,7 +192,7 @@ def allow(self, peer): @pytest.mark.asyncio async def test_dynamic_key_authentication_and_mcp_identity(backend, monkeypatch): monkeypatch.setenv("DEEP_AGENTIC_CORE_MCP_PUBLIC_URL", "https://mcp.example") - monkeypatch.delenv("DEEP_AGENTIC_CORE_MCP_REMOTE_TRANSPORT", raising=False) + monkeypatch.setenv("DEEP_AGENTIC_CORE_MCP_REMOTE", "0") store = RedisSessionStore("redis://localhost") store.client = fakeredis.FakeRedis(decode_responses=True) app = create_app(key_store=backend, store=store, allowed_hosts=["mcp.example"]) From 1b020d95ec31b6af21894704b7e11e7440fad541 Mon Sep 17 00:00:00 2001 From: PRAMOD B N Date: Tue, 20 Oct 2026 02:20:16 -0500 Subject: [PATCH 3/7] feat(storage): add PostgreSQL/Supabase key, session and signup stores --- deploy/supabase/schema.sql | 50 +++++ pyproject.toml | 1 + .../services/postgres_store.py | 184 ++++++++++++++++++ uv.lock | 117 +++++++++++ 4 files changed, 352 insertions(+) create mode 100644 deploy/supabase/schema.sql create mode 100644 src/deep_agentic_core_mcp/services/postgres_store.py diff --git a/deploy/supabase/schema.sql b/deploy/supabase/schema.sql new file mode 100644 index 0000000..5bb99f9 --- /dev/null +++ b/deploy/supabase/schema.sql @@ -0,0 +1,50 @@ +-- Storage for the hosted MCP service (keys, sessions, signup limits). +-- The private "mcp" schema is not exposed through the Supabase Data API. +-- Keys are stored only as SHA-256 digests; payloads are per-user tool state. +CREATE SCHEMA IF NOT EXISTS mcp; +REVOKE ALL ON SCHEMA mcp FROM PUBLIC; + +CREATE TABLE IF NOT EXISTS mcp.users ( + user_id text PRIMARY KEY CHECK (length(user_id) BETWEEN 1 AND 128), + display_name text NOT NULL, + status text NOT NULL CHECK (status IN ('active', 'revoked')), + key_hash text NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS mcp.sessions ( + owner_hash text PRIMARY KEY, + payload text NOT NULL, + expires_at timestamptz NOT NULL +); +CREATE INDEX IF NOT EXISTS sessions_expires_at ON mcp.sessions (expires_at); + +CREATE TABLE IF NOT EXISTS mcp.signup_counters ( + bucket text PRIMARY KEY, + count integer NOT NULL, + expires_at timestamptz NOT NULL +); + +-- Defense in depth: only mcp_service has a policy; Data API roles see no rows. +ALTER TABLE mcp.users ENABLE ROW LEVEL SECURITY; +ALTER TABLE mcp.sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE mcp.signup_counters ENABLE ROW LEVEL SECURITY; + +-- Least-privilege login used by the service. Set its password separately: +-- ALTER ROLE mcp_service WITH PASSWORD ''; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'mcp_service') THEN + CREATE ROLE mcp_service LOGIN; + END IF; +END +$$; +GRANT USAGE ON SCHEMA mcp TO mcp_service; +GRANT SELECT, INSERT, UPDATE ON mcp.users TO mcp_service; +GRANT SELECT, INSERT, UPDATE, DELETE ON mcp.sessions, mcp.signup_counters TO mcp_service; +DROP POLICY IF EXISTS service_access ON mcp.users; +DROP POLICY IF EXISTS service_access ON mcp.sessions; +DROP POLICY IF EXISTS service_access ON mcp.signup_counters; +CREATE POLICY service_access ON mcp.users TO mcp_service USING (true) WITH CHECK (true); +CREATE POLICY service_access ON mcp.sessions TO mcp_service USING (true) WITH CHECK (true); +CREATE POLICY service_access ON mcp.signup_counters TO mcp_service USING (true) WITH CHECK (true); diff --git a/pyproject.toml b/pyproject.toml index c3b527c..9d546ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ docs = [ ] http = [ "boto3>=1.34,<2", + "psycopg[binary,pool]>=3.2,<4", "starlette>=0.37", "uvicorn>=0.30", "redis>=5,<7", diff --git a/src/deep_agentic_core_mcp/services/postgres_store.py b/src/deep_agentic_core_mcp/services/postgres_store.py new file mode 100644 index 0000000..b05ca1a --- /dev/null +++ b/src/deep_agentic_core_mcp/services/postgres_store.py @@ -0,0 +1,184 @@ +"""PostgreSQL (e.g. Supabase) backends for keys, sessions, and signup limits. + +Apply deploy/supabase/schema.sql first. One connection pool is shared by all +three stores. Prepared statements are disabled so Supabase's transaction-mode +pooler (port 6543) works; the session pooler and direct connections work too. +""" + +from __future__ import annotations + +import hashlib +import random +import secrets +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any + +from deep_agentic_core_mcp.services import session +from deep_agentic_core_mcp.services.api_keys import KeyConflictError, key_digest +from deep_agentic_core_mcp.services.remote_sessions import decode, encode + + +def connect(url: str, *, max_size: int = 10) -> Any: + """Open a pool; connections commit on success and roll back on error.""" + from psycopg_pool import ConnectionPool + + return ConnectionPool( + url, + min_size=1, + max_size=max_size, + timeout=10, + kwargs={"prepare_threshold": None, "connect_timeout": 5}, + open=True, + ) + + +class PostgresKeyStore: + """One active key per user in mcp.users; reads are immediately consistent.""" + + def __init__(self, pool: Any) -> None: + self.pool = pool + + def ping(self) -> bool: + with self.pool.connection() as conn: + conn.execute("SELECT 1 FROM mcp.users LIMIT 1") + return True + + def authenticate(self, token: str) -> str | None: + if len(token) < 32 or len(token) > 256 or any(c.isspace() for c in token): + return None + with self.pool.connection() as conn: + row = conn.execute( + "SELECT user_id FROM mcp.users WHERE key_hash = %s AND status = 'active'", + (key_digest(token),), + ).fetchone() + return str(row[0]) if row else None + + def issue(self, display_name: str) -> dict[str, str]: + user = str(uuid.uuid4()) + token = "dal_" + secrets.token_urlsafe(36) + self.import_key(user, token, display_name) + return {"user_id": user, "display_name": display_name, "api_key": token} + + def import_key(self, user: str, token: str, display_name: str) -> None: + # Unique constraints reject an existing user ID or key; nothing is overwritten. + with self.pool.connection() as conn: + conn.execute( + "INSERT INTO mcp.users (user_id, display_name, status, key_hash)" + " VALUES (%s, %s, 'active', %s)", + (user, display_name, key_digest(token)), + ) + + def profile(self, user: str) -> dict[str, str]: + with self.pool.connection() as conn: + row = conn.execute( + "SELECT display_name, status FROM mcp.users WHERE user_id = %s", (user,) + ).fetchone() + return { + "user_id": user, + "display_name": str(row[0]) if row else "", + "status": str(row[1]) if row else "unknown", + } + + def rotate(self, user: str, old_token: str) -> dict[str, str]: + token = "dal_" + secrets.token_urlsafe(36) + with self.pool.connection() as conn: + updated = conn.execute( + "UPDATE mcp.users SET key_hash = %s" + " WHERE user_id = %s AND key_hash = %s AND status = 'active'", + (key_digest(token), user, key_digest(old_token)), + ).rowcount + if updated != 1: + raise KeyConflictError("Account changed") + return {"user_id": user, "api_key": token} + + def revoke(self, user: str, token: str) -> None: + with self.pool.connection() as conn: + updated = conn.execute( + "UPDATE mcp.users SET status = 'revoked'" + " WHERE user_id = %s AND key_hash = %s AND status = 'active'", + (user, key_digest(token)), + ).rowcount + if updated != 1: + raise KeyConflictError("Account changed") + + +class PostgresSessionStore: + """Per-user state in one row, locked for the duration of each tool call. + + Row locks serialize a user's calls across processes; other users never wait. + """ + + def __init__(self, pool: Any, ttl_seconds: int = 3600) -> None: + if ttl_seconds < 1: + raise ValueError("Session TTL must be positive") + self.pool = pool + self.ttl_seconds = ttl_seconds + + def ping(self) -> bool: + with self.pool.connection() as conn: + conn.execute("SELECT 1 FROM mcp.sessions LIMIT 1") + return True + + @contextmanager + def transaction(self, owner: str) -> Iterator[None]: + key = hashlib.sha256(owner.encode()).hexdigest() + with self.pool.connection() as conn: + conn.execute("SET LOCAL lock_timeout = '10s'") + if random.random() < 0.01: + conn.execute("DELETE FROM mcp.sessions WHERE expires_at < now()") + conn.execute( + "INSERT INTO mcp.sessions (owner_hash, payload, expires_at)" + " VALUES (%s, '{}', now()) ON CONFLICT (owner_hash) DO NOTHING", + (key,), + ) + row = conn.execute( + "SELECT payload, expires_at > now() FROM mcp.sessions" + " WHERE owner_hash = %s FOR UPDATE", + (key,), + ).fetchone() + states = decode(row[0] if row and row[1] else None) + with session.session_scope(states): + yield + payload = encode(states) + conn.execute( + "UPDATE mcp.sessions SET payload = %s," + " expires_at = now() + make_interval(secs => %s) WHERE owner_hash = %s", + (payload, self.ttl_seconds, key), + ) + + +class PostgresSignupLimiter: + """Shared limits: 10 signups/hour/client and 100/day across the service.""" + + def __init__(self, pool: Any) -> None: + self.pool = pool + + def allow(self, peer: str) -> bool: + # Fixed lock order (client bucket, then global) avoids deadlocks. + buckets = [ + ("ip:" + hashlib.sha256(peer.encode()).hexdigest(), 10, 3600), + ("global", 100, 86400), + ] + with self.pool.connection() as conn: + counts = [] + for bucket, _, window in buckets: + row = conn.execute( + "INSERT INTO mcp.signup_counters AS c (bucket, count, expires_at)" + " VALUES (%s, 0, now() + make_interval(secs => %s))" + " ON CONFLICT (bucket) DO UPDATE SET" + " count = CASE WHEN c.expires_at <= now() THEN 0 ELSE c.count END," + " expires_at = CASE WHEN c.expires_at <= now()" + " THEN excluded.expires_at ELSE c.expires_at END" + " RETURNING count", + (bucket, window), + ).fetchone() + counts.append(row[0] if row else 0) + if any(count >= limit for count, (_, limit, _) in zip(counts, buckets, strict=True)): + return False + conn.execute( + "UPDATE mcp.signup_counters SET count = count + 1 WHERE bucket = ANY(%s)", + ([bucket for bucket, _, _ in buckets],), + ) + return True diff --git a/uv.lock b/uv.lock index 025cf1a..c74c179 100644 --- a/uv.lock +++ b/uv.lock @@ -660,6 +660,7 @@ docs = [ ] http = [ { name = "boto3" }, + { name = "psycopg", extra = ["binary", "pool"] }, { name = "redis" }, { name = "starlette" }, { name = "uvicorn" }, @@ -677,6 +678,7 @@ requires-dist = [ { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.7.7" }, { name = "moto", extras = ["dynamodb"], marker = "extra == 'dev'", specifier = ">=5,<6" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "psycopg", extras = ["binary", "pool"], marker = "extra == 'http'", specifier = ">=3.2,<4" }, { name = "pydantic", specifier = ">=2.0,<3" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, @@ -1671,6 +1673,112 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] +pool = [ + { name = "psycopg-pool" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/92/00350a66de0af05e41d01aa3134e3970045e816afed3f99d58ec1abe15b2/psycopg_binary-3.3.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7beb3e41c9a1e509f3ed85263386588cbe3e975aa67be21f79f44fd35ffaeefc", size = 4728682, upload-time = "2026-09-18T13:15:36.605Z" }, + { url = "https://files.pythonhosted.org/packages/91/fc/afa9c7fd316a469af7ede6ebb020eac482f5d827fae57d5310c9bc0c41ae/psycopg_binary-3.3.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa73160077345ec21b3f51e8e24b3de2e99586217e497629326eb9b2ea88c52e", size = 4777111, upload-time = "2026-09-18T13:15:46.566Z" }, + { url = "https://files.pythonhosted.org/packages/f2/44/7c1e015f1bc56b36ff1369f09e852b2d83ccefd5a669a42633a916cdedc4/psycopg_binary-3.3.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f87dbdc42e78ee0f7ea180c03f8c78e80a949e373066629bd90fefff10552dff", size = 5592755, upload-time = "2026-09-18T13:15:52.886Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ae/314a251ca918cdac380bce1b87839ade9355382ea749e6ef3ba75ba0c09f/psycopg_binary-3.3.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a9348c5b43a3bb5ef8c2e89d5237c9c87eeafb01d338c84a7aebbc5cd0313299", size = 5267949, upload-time = "2026-09-18T13:16:00.53Z" }, + { url = "https://files.pythonhosted.org/packages/b6/9f/3bb0cfe9bb0f31ca57cf486ddc8c9ac51251aed8181bf88ff870b2623105/psycopg_binary-3.3.6-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a52991594ac4db888c7d39bccef331797e30cb31a95cae02cf2607f83a42dc2", size = 6862403, upload-time = "2026-09-18T13:16:10.385Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d6/7032c10309c3155e9b24300fdcc9a1afa539cfd20ce52fdef74a46f10161/psycopg_binary-3.3.6-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ea8beeb5541780b4b50b462eeacbc4f594ce3b911dc20c81c75f267876f71d2", size = 5107271, upload-time = "2026-09-18T13:16:16.843Z" }, + { url = "https://files.pythonhosted.org/packages/61/cc/79add2cf92684cf1a81da134b32caa662c25c72d0cc905d181ef4455f834/psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:198a48e68cc99ccac03ba95ac857e73aa66f3bf6be77019fafb0832a05f7ad03", size = 4627984, upload-time = "2026-09-18T13:16:23.889Z" }, + { url = "https://files.pythonhosted.org/packages/c9/48/6dfb14f9350c14af6a2edb3c31262051b8cd94e2186e4b831e46dbbe8cd9/psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fa34eb47969297471db7b7f193622c7e3ee839ec05abd05f1fe104d5b1b1dcf4", size = 4322054, upload-time = "2026-09-18T13:16:29.33Z" }, + { url = "https://files.pythonhosted.org/packages/29/35/2982338716a91cbb4dfc866be015be4457ee8106a445aabf3d1fb6a270e0/psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b979a42815410432420275412633960807178b1ce26591a16ce06e78a5bd4bb2", size = 4048471, upload-time = "2026-09-18T13:16:34.119Z" }, + { url = "https://files.pythonhosted.org/packages/24/e1/171b1db1542c5f76a678b7ee0a7800bebc9735a0a03417c76cf948bfd63c/psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:889e42acec10450185e0cdfb396f375e2c1a8d7737c114830a7fde4654f59e30", size = 4353931, upload-time = "2026-09-18T13:16:38.692Z" }, + { url = "https://files.pythonhosted.org/packages/08/89/4424e62a944eef40bd9326ada4ae23802b28eab6502af91e84ef7bba74fb/psycopg_binary-3.3.6-cp310-cp310-win_amd64.whl", hash = "sha256:cbd5f73073ed19c378d4c35499db1e3e703a5b1a324e521204065967bfaa7a18", size = 3674362, upload-time = "2026-09-18T13:16:44.454Z" }, + { url = "https://files.pythonhosted.org/packages/70/86/b71166048974d49c6d136b2ed1c0e5bec0b974d8c4de5cbce7e86a9e412a/psycopg_binary-3.3.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:be4f9b3c9338ac5dd217c5847e21521b396c8117f78dc420d495a5c49bbef874", size = 4728002, upload-time = "2026-09-18T13:16:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/1d/1e06c0de7ed5aed898acb87544eac6ef0bc7d752a67ec6e5d6b835e9b40c/psycopg_binary-3.3.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f0535693ce476a722b718b002d5d2c27d47e71ca945276ac194409c98e74c492", size = 4775403, upload-time = "2026-09-18T13:16:58.939Z" }, + { url = "https://files.pythonhosted.org/packages/84/02/2ffcbc43f8e4bbc38e5286a22013bcac01898d13cd38325f60dd5428a8af/psycopg_binary-3.3.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3c9e663b2e800e3218994cf948c11bcc2844e6491b34aa80d089baf6531827bf", size = 5594145, upload-time = "2026-09-18T13:17:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/e1/25/031dae2c7d2e7e77dcf5b1962c1e0684fa548d7af0ff6707b6b5e6054ca7/psycopg_binary-3.3.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a2e44a342d2aee40508e28a563d8961c39d9bbd8cae36d8578f0a3c6658aab0f", size = 5267985, upload-time = "2026-09-18T13:17:16.24Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/94c89ada3c003a4d858178f3bba49a35e0297ef2aad659b80eb5e380e690/psycopg_binary-3.3.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f598f19fa9a91540b5cee17932ffd227b7b53a481605bcc4573c0eafa647300", size = 6860986, upload-time = "2026-09-18T13:17:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a0/81bf499d095adee8413bd19822a6872fbfa21663ec78014a68d83a8db83c/psycopg_binary-3.3.6-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff05561e4a067d35507dc5c90f1deb2ec1c9703ac5cccc1bc26e08a197f9c5a", size = 5106702, upload-time = "2026-09-18T13:17:28.847Z" }, + { url = "https://files.pythonhosted.org/packages/00/75/99d56da64c27bd985fd82c6ecbf7976b724ac638fdd1654ef995323a1a26/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:566dd827f17728efdf7d88a5b066f815170f6fdad13967ae952842d90e6aaa9f", size = 4627090, upload-time = "2026-09-18T13:17:36.668Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0c/0222171d11233332c6a24b1cef1578215f0ffddf3642eb8dd8c4448ad69f/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9b2f11794e017ce340934e35de46181c46ef71ec75ea3d85dd75cd836761c01e", size = 4320353, upload-time = "2026-09-18T13:17:42.526Z" }, + { url = "https://files.pythonhosted.org/packages/62/6f/e1cc2a28dd1228c67c969ba6fd37cd8726b312e2ff51380f847ddb38ccde/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:910ace140e3e7b7596898d083f37a8fe90c5c40684252ad4e682364b2cd3deba", size = 4047253, upload-time = "2026-09-18T13:17:47.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fd/38b64790ce7a515b1dbd2bab3d119637a858aeb22c380cf4859bc4ce0e42/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37e517c146b185f9c0c6e8d0a0ebbdeeeb67896af28466e032bc810d0c7dc7a7", size = 4353208, upload-time = "2026-09-18T13:17:52.41Z" }, + { url = "https://files.pythonhosted.org/packages/f7/dc/45386530ceb2a8c789a226de9b9b34eca8fccf1feba2e4ef68a6aca50c56/psycopg_binary-3.3.6-cp311-cp311-win_amd64.whl", hash = "sha256:c7f92daa0d2a1c76f07264abddf8cbabd30152a2f09c3270e50f0c7efdf5dcac", size = 3675638, upload-time = "2026-09-18T13:17:58.112Z" }, + { url = "https://files.pythonhosted.org/packages/e6/01/2cdd1824e58b4467ee0b9498664cd28c42d8794db6b1e35b6bcb834f0044/psycopg_binary-3.3.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f84dab25e0385692ee13274c68678377e0b1a70ab9d14e56264cbf61f60c62d", size = 4707086, upload-time = "2026-09-18T13:18:05.138Z" }, + { url = "https://files.pythonhosted.org/packages/f6/76/de9948ac06895261c84d5b9fbe283d8f3c5bc9f070691b8d9eaa1b51e322/psycopg_binary-3.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:612382ac3ed13651c7fa44b5fee9fbf7baaa2ddbc6f500391672682c5f1df9e0", size = 4769607, upload-time = "2026-09-18T13:18:12.83Z" }, + { url = "https://files.pythonhosted.org/packages/76/a9/72436c9915ee4905964689e7f0e182ce7767cc0a0390b3ce703be8177625/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:366db6e97e66b37211475f20c4c1324a2dc0dd825e46d4e87f9d599304d276f9", size = 5554134, upload-time = "2026-09-18T13:18:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/0a/42/948bb3d2617795093512613fd96ba380e922992c7908fbc073858147d196/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1679a1cb93fbe5a6d1fd58d82cbddcc6fcb8c61446ba7cae6eb2a7b19bc585de", size = 5235723, upload-time = "2026-09-18T13:18:27.071Z" }, + { url = "https://files.pythonhosted.org/packages/99/47/93e823ff1b0088400703410939c9bda3e63ed9c850b3ee088e8769f4c10b/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d40450659401600e6d043ff586c89a71a69f33cbb8bcdba6cdb2569beecdbe", size = 6833587, upload-time = "2026-09-18T13:18:33.794Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/ecc69c847795aa704041a9f5667a6b0938a088cf1853636d762a6938e493/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5165300324efd5a772c48a88ab3a928513ab3979fca76553e62ee815f7b2b9c", size = 5070013, upload-time = "2026-09-18T13:18:39.628Z" }, + { url = "https://files.pythonhosted.org/packages/92/36/6126f0dac21713dcae91404f2a76da18598a6252339a8c669c46370d43b2/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d636338c8f21b0df2f84657b00bc34f9313f826ef93f1155bc743607e4a0c5eb", size = 4597367, upload-time = "2026-09-18T13:18:45.023Z" }, + { url = "https://files.pythonhosted.org/packages/4d/29/7ecfc04243b46c89ffd49924e9c5634ea904ef96c7d0f37e4073623584c1/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a4ee3bdd5468a725f2a4d9aab8a74b6d0279f768c8b5d3aeb102c5307ff3d59c", size = 4275419, upload-time = "2026-09-18T13:18:49.299Z" }, + { url = "https://files.pythonhosted.org/packages/6e/90/2f46d2e0de79706ac170df0a3637fe63c4498fc04f131f6049520b78b806/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:289aadd6a00e151203c081f708348ec89f1e483c9b510ef4ac3981f847f01f79", size = 4007358, upload-time = "2026-09-18T13:18:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/03/48/6744e91291b751a8cf12d63d719977974bb94c84ceba913e7ddb2e478e51/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f21d057f3e5f5491067e5b292498073b73847d48799b099803fef100775fcc52", size = 4320156, upload-time = "2026-09-18T13:18:59.258Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9b/94ff7fce53a64d5b286e2ec454e0a025cf3d6e6b4a9189bef16aa5de98b2/psycopg_binary-3.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:e23a66a763fbe83fcc210bc77c27e5a5ea380ebf091c06f34d8561b695e5a40f", size = 3658864, upload-time = "2026-09-18T13:19:06.503Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c3/c072584b69ad44a747b448cfc9766fecb8aae56e372a017e2ef668790057/psycopg_binary-3.3.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8f35e67cc16d1fad1fa8c88972dc9b3a3141ea67897399904edab96a301b6", size = 4712284, upload-time = "2026-09-18T13:19:13.451Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b9/4283b785339e8e2318d03048994b093d650ea6289fabaa806b765dc0d449/psycopg_binary-3.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373704aea331d3f3e3402c125a1543f5875e2986ebb54f97d1647942161f803f", size = 4772031, upload-time = "2026-09-18T13:19:18.524Z" }, + { url = "https://files.pythonhosted.org/packages/6f/72/7a1321d359246769fff1affffbd0132785a28f7f63c18524c15a502398f4/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b82491019b884d62318b5f30706c3d7e6d4e5a6cb7eabcb3edc0c1b0fdaceae9", size = 5556392, upload-time = "2026-09-18T13:19:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/de/b0/c6f8a0585a5dacbea74e130bcfc66629390e8f5bbc79d2a8e806e8952150/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cec5ea900390897d0b46130f60bc2883bf19c314f9044235217c8be88b0ef269", size = 5237855, upload-time = "2026-09-18T13:19:31.257Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fc/c3a7a8bbef7e945ec584ac61d460a612363ea398511cd0e220242b1d69f1/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98c02090d88f2ebc0ec1e8da538f77d225ce0fffecf372aa39262e62a1b054ef", size = 6833856, upload-time = "2026-09-18T13:19:43.622Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f2/8e80b921db728ebb68fc105bd7c4277f908210ad755bd6481d5ea7add740/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ee2c4728c691245e24501fcd7a97b5b381236b9985bc445bba88cdce7d1b5784", size = 5070730, upload-time = "2026-09-18T13:19:49.968Z" }, + { url = "https://files.pythonhosted.org/packages/54/6a/5b313e0c5348244f0e973aff3258bf86766656256d5ece8d541a53e35b4a/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f19cc87343eaa55255e76b31259a570072ac95d6ae82c92dd34b97691f5e49dc", size = 4598089, upload-time = "2026-09-18T13:19:56.426Z" }, + { url = "https://files.pythonhosted.org/packages/32/e9/db7f76ec24bf6699e92bf604e5c4bae10664a681a8999ef42aa0faf0f2c6/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdccb3a0e184b03e9baa673b15a809cf36c339c85dbda0ebc25a698846dfbee8", size = 4278481, upload-time = "2026-09-18T13:20:04.681Z" }, + { url = "https://files.pythonhosted.org/packages/61/83/72c67013656f4d6b547caabffb193e91d57e63f90eefdcc6d045c400e97d/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9892188bb15e5803beb51afe8a25add6b56be391a53058e8bca03b74e1e6bf22", size = 4009229, upload-time = "2026-09-18T13:20:11.905Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/5e4500df2c999eb0faed8b184e6958b834172128274f06167a5deef4c19c/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3af90f92769d8cc10f94515ee7a0aef36ea85ca733a0ce22858f6e0953f41138", size = 4321467, upload-time = "2026-09-18T13:20:17.949Z" }, + { url = "https://files.pythonhosted.org/packages/55/7f/e350e1cf498ba2565c3f87b12f429d2012eb86b76c2b3845a19ee5fbb4d6/psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372", size = 3658179, upload-time = "2026-09-18T13:20:22.691Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b9/60711317c284a442511644ea7185b56ebe627606d6741e732cd16108c47b/psycopg_binary-3.3.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b3f75dee0f9afafabe4edc52c4842f1e1878ed2069bd05b22d6fe961e97e4dba", size = 4720512, upload-time = "2026-09-18T13:20:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/63/da/28befc84454cbc6374550de7746f591f8fe1b6165c1fce249652cc8291c4/psycopg_binary-3.3.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5927b7ba63153cd8e9862987290a2b783a5c590daf2a4ef981700cc3569166d4", size = 4782318, upload-time = "2026-09-18T13:20:35.401Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8a/0d21c2c833cdc0d4244c77e858e0ed37fa2abec2623be4fd686f617109ce/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0bf08b749cc144f33b44a91b78e3f71c60eb07963746a0df5a100b36ce3d7475", size = 5567460, upload-time = "2026-09-18T13:20:41.902Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/7692d0d4e656b6cc9868d8acc2e3b42f17a0db4a625400a6d093cb0533a1/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31cd942c23f613276b81a6e6598cefa12960058b0f46e1e874b540c793f6aca5", size = 5246902, upload-time = "2026-09-18T13:20:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c1/b8a1f18fb1b7558a17f57f7cb3fc8bc93189feea2958925950b3acb15743/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4690cf67738f0e0e49a32aeec99bf0e4595cc2b4f1af984a4345394b1dcff91a", size = 6847192, upload-time = "2026-09-18T13:20:56.874Z" }, + { url = "https://files.pythonhosted.org/packages/a5/76/404f33519167c65cca88ec4998776f1dbebccc301ee977f0e62c47fb0826/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ad1c785e784cfd87e8436c6b7702f2d321fc39601bbaf29bc63a41a867091638", size = 5079573, upload-time = "2026-09-18T13:21:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d9/79e8fbc8f37262a415f3550f0bcc5f98037442bf3d12ef6cbae2056655ae/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:79a2a1c3449f6c3409427078ed1cec10de79f3023cb5f2504f0597d350ad46c7", size = 4613633, upload-time = "2026-09-18T13:21:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/d4/47/96225db74be7d2ce04b3a58678b53cda610225055edf5faa775c9f501d8b/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:86147cb5d140341c3363fb5bacce31f8d5543902a46699d3c536b101bbceaf9e", size = 4293375, upload-time = "2026-09-18T13:21:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d2/18e9c779a5efd565250329adaf529ecc2b8b2ed5be5cb0f6ccee208cbfd9/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7308c93cf0b19bbaf8e6ff0a6ad50d3c442385739245fe15a8d593bf841734a6", size = 4019883, upload-time = "2026-09-18T13:21:21.587Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/0cc654afc6c2cda982767f5679d3646b30b1ec86545bdaa9402202d6776c/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05a83ac9fd52b9bca7cb5ab04b3691163170bd16f53defa27216ea3aa07ee781", size = 4332607, upload-time = "2026-09-18T13:21:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3e/0a753a74fbd7aef120f286c016e09d3cc3f1daf7688f4a145d27281260b2/psycopg_binary-3.3.6-cp314-cp314-win_amd64.whl", hash = "sha256:1fbd30e537dab22cafdf080608f10148fe2a5f3a61294ddb5113caac8a623840", size = 3755671, upload-time = "2026-09-18T13:21:33.855Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b1/a372b9c02aea50148e71c9853e19efca8fa5ae2010a8e27243b9b8f790c0/psycopg_binary-3.3.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:bf8c8481d026b85dd70c5fa7dde85b2333aed0b32a2602bcd38a900cbd78a49c", size = 4719571, upload-time = "2026-09-18T13:21:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/65/7c/811e3828c6b82e2f10c6c9cdd963cfc66f3e024026e5a69ac18530bad984/psycopg_binary-3.3.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b599defe9190b17e9907c8b4d114c181e702c87efcd1b8a0ad40971cdcc4634a", size = 4781230, upload-time = "2026-09-18T13:21:49.516Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/9a784eed813ea9e97c294af3ead63d02b7b203502c66380336c50065e441/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b8ece331509f7a975b90501f41e83ad905e4141753fedf3f2711b2bc70a8efbc", size = 5566111, upload-time = "2026-09-18T13:21:58.089Z" }, + { url = "https://files.pythonhosted.org/packages/68/16/47194e002007c27337b11e49bf459c4b19727463f9aff2e1a90917bcc806/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c61617eaae0112ca154da87ffb99b73af2c74067acac28dfb9a4455b019dff2e", size = 5249963, upload-time = "2026-09-18T13:22:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/5dcf9f310b11f0675cd860c6b2c70f58ce61798a3ee3f6f962b53fa358ca/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6d19cb4999d03231e8730a5f66c8f5068bc3b532677eb39dab0f600bff3e312", size = 6847925, upload-time = "2026-09-18T13:22:13.088Z" }, + { url = "https://files.pythonhosted.org/packages/f3/06/1957a06dc22963c418c27b284929579de84f29c37ad1abe6dc6ee9e8cf25/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8cbb54454dbf1bbf2ff08dd7693e8d94ac94b1a20f70f4b3b813d52ecb5cbc1", size = 5087720, upload-time = "2026-09-18T13:22:17.959Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/ac07d042bae99b57bf123bb473632f29af544008094da0ffd285ab8011e2/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dc75da5a20951049f7b773145f998f69d181adad9c58a0ff36e0cf1d73c10e10", size = 4613412, upload-time = "2026-09-18T13:22:26.719Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/019156fbeafcefb4cccc9d109de4699493bceb8313c7545c8349e089dfbc/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:955e3dd94da361e052d2e49acf591017158dc8f8ed2c8a42c2e3943403c39dc2", size = 4292618, upload-time = "2026-09-18T13:22:33.042Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0f/62113dc6b1df65983a1f2fc816c04b1edfa22f2ae9d4abee74ed267f4a96/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:c7753871eb57e6a5f4646f6168590c6653073dea5e9e720b201c8875332df4c8", size = 4027121, upload-time = "2026-09-18T13:22:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d5/cf0cbd1ea5a7d8167fe2c6953efde19101f7b193bd61a23e6d622ad6854c/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:303732e798fe6729f8e12021b9c96107df8e95ecec4dd487c67b98ec2a59435e", size = 4336388, upload-time = "2026-09-18T13:22:45.576Z" }, + { url = "https://files.pythonhosted.org/packages/98/33/e2a5b36edf8aa422f6fa4b894756eb33dc93b36df5f65121280bb8b929c4/psycopg_binary-3.3.6-cp315-cp315-win_amd64.whl", hash = "sha256:2f122603f36050937982abf9668d8bc4769a79f7c93a65013b1c49f1cab7b56b", size = 3756154, upload-time = "2026-09-18T13:22:51.283Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/5e/c0664b968b102ff68b811d999c728546c48d5c1eec03e3bbaf88c0cb4472/psycopg_pool-3.3.3.tar.gz", hash = "sha256:df87b5d9d0ad7db37f6cdad4fa8ce113d250f5997f6db38e9a99192fb67f9e1d", size = 32006, upload-time = "2026-09-22T15:53:24.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/b4/452c6607a0f479465cd8a9b0d9956919fcb150050c1f83f9f11e6b8ee8dc/psycopg_pool-3.3.3-py3-none-any.whl", hash = "sha256:9b9cd6a4fcec47a410f7e82d408540e7f77b478509e91b44c1a5457a13e5ff37", size = 40304, upload-time = "2026-09-22T15:53:23.712Z" }, +] + [[package]] name = "py-partiql-parser" version = "0.6.3" @@ -2613,6 +2721,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" From 4f55ae0c7d85bcb194e14472152ac7cffcc9a2da Mon Sep 17 00:00:00 2001 From: PRAMOD B N Date: Tue, 20 Oct 2026 02:20:16 -0500 Subject: [PATCH 4/7] feat(http): select Supabase storage via DEEP_AGENTIC_CORE_MCP_DATABASE_URL --- src/deep_agentic_core_mcp/transport_http.py | 53 ++++-- tests/test_postgres_store.py | 201 ++++++++++++++++++++ 2 files changed, 242 insertions(+), 12 deletions(-) create mode 100644 tests/test_postgres_store.py diff --git a/src/deep_agentic_core_mcp/transport_http.py b/src/deep_agentic_core_mcp/transport_http.py index 0c9c0f7..32045e2 100644 --- a/src/deep_agentic_core_mcp/transport_http.py +++ b/src/deep_agentic_core_mcp/transport_http.py @@ -12,6 +12,8 @@ import os import time from collections.abc import AsyncIterator, Mapping +from typing import Any +from urllib.parse import urlsplit from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings @@ -24,6 +26,12 @@ from deep_agentic_core_mcp import config from deep_agentic_core_mcp.server import server from deep_agentic_core_mcp.services.api_keys import DynamoKeyStore, KeyStore, StaticKeyStore +from deep_agentic_core_mcp.services.postgres_store import ( + PostgresKeyStore, + PostgresSessionStore, + PostgresSignupLimiter, + connect, +) from deep_agentic_core_mcp.services.remote_sessions import RedisSessionStore, SessionStore @@ -38,18 +46,26 @@ def create_app( max_concurrent_per_user: int = 2, max_concurrent_total: int = 16, ) -> Starlette: - """Create a fail-closed app; an authentication backend and Redis are required. + """Create a fail-closed app; authentication and shared session storage are required. api_keys maps stable user IDs to unique secrets of at least 32 characters. Tests/development may explicitly supply MemorySessionStore. Stateless MCP transport avoids task affinity; workflow state lives in the supplied store. Rate/concurrency limits apply per process, storage quotas apply per user. + DEEP_AGENTIC_CORE_MCP_DATABASE_URL selects PostgreSQL (e.g. Supabase) for + keys and, unless Redis is configured, sessions and signup limits. """ if key_store is not None and api_keys is not None: raise ValueError("Supply only one authentication backend") + database_url = os.environ.get("DEEP_AGENTIC_CORE_MCP_DATABASE_URL") + pool: Any = None + if database_url and (store is None or (key_store is None and api_keys is None)): + pool = connect(database_url) if key_store is None: table = os.environ.get("DEEP_AGENTIC_CORE_MCP_USERS_TABLE") - if table and api_keys is None: + if pool is not None and api_keys is None: + key_store = PostgresKeyStore(pool) + elif table and api_keys is None: key_store = DynamoKeyStore(table) else: if api_keys is None: @@ -60,9 +76,15 @@ def create_app( raise ValueError("Request and concurrency limits must be positive") if store is None: url = os.environ.get("DEEP_AGENTIC_CORE_MCP_REDIS_URL") - if not url: - raise ValueError("Configure DEEP_AGENTIC_CORE_MCP_REDIS_URL for shared session storage") - store = RedisSessionStore(url) + if url: + store = RedisSessionStore(url) + elif pool is not None: + store = PostgresSessionStore(pool) + else: + raise ValueError( + "Configure DEEP_AGENTIC_CORE_MCP_REDIS_URL or DEEP_AGENTIC_CORE_MCP_DATABASE_URL" + " for shared session storage" + ) session_store = store config.enable_remote_transport() session_manager = StreamableHTTPSessionManager( @@ -153,19 +175,23 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: endpoint = MCPEndpoint() routes = [Route("/healthz", healthz), Route("/mcp", endpoint), Route("/mcp/", endpoint)] - if isinstance(auth_store, DynamoKeyStore): - from deep_agentic_core_mcp.signup import RedisSignupLimiter, signup_routes + if isinstance(auth_store, (DynamoKeyStore, PostgresKeyStore)): + from deep_agentic_core_mcp.signup import RedisSignupLimiter, SignupLimiter, signup_routes public_url = os.environ.get("DEEP_AGENTIC_CORE_MCP_PUBLIC_URL", "") - if not public_url.startswith("https://") or not isinstance( - session_store, RedisSessionStore - ): + limiter: SignupLimiter | None = None + if isinstance(session_store, RedisSessionStore): + limiter = RedisSignupLimiter(session_store.client) + elif isinstance(auth_store, PostgresKeyStore): + limiter = PostgresSignupLimiter(auth_store.pool) + if not public_url.startswith("https://") or limiter is None: raise ValueError( - "Signup requires an HTTPS public URL and Redis for shared signup limits" + "Signup requires an HTTPS public URL and Redis or PostgreSQL for shared" + " signup limits" ) routes += signup_routes( auth_store, - RedisSignupLimiter(session_store.client), + limiter, public_url, trusted_proxy_hops=int(os.environ.get("DEEP_AGENTIC_CORE_MCP_TRUSTED_PROXY_HOPS", "0")), allowed_origins=allowed_origins, @@ -184,6 +210,9 @@ def main() -> None: render_hostname = os.environ.get("RENDER_EXTERNAL_HOSTNAME") if render_hostname: default_hosts += "," + render_hostname + public_host = urlsplit(os.environ.get("DEEP_AGENTIC_CORE_MCP_PUBLIC_URL", "")).hostname + if public_host: + default_hosts += "," + public_host hosts = os.environ.get("DEEP_AGENTIC_CORE_MCP_ALLOWED_HOSTS", default_hosts) origins = os.environ.get("DEEP_AGENTIC_CORE_MCP_ALLOWED_ORIGINS", "") app = create_app( diff --git a/tests/test_postgres_store.py b/tests/test_postgres_store.py new file mode 100644 index 0000000..6d08419 --- /dev/null +++ b/tests/test_postgres_store.py @@ -0,0 +1,201 @@ +"""PostgreSQL/Supabase keys, sessions, and signup limits against a real database. + +Set DEEP_AGENTIC_CORE_MCP_TEST_DATABASE_URL to a disposable superuser database, e.g. +docker run --rm -e POSTGRES_PASSWORD=pw -p 55432:5432 postgres:16-alpine +""" + +import json +import os +import threading +import time +from pathlib import Path + +import httpx +import pytest + +from deep_agentic_core_mcp.services import session +from deep_agentic_core_mcp.services.api_keys import KeyConflictError + +ADMIN_URL = os.environ.get("DEEP_AGENTIC_CORE_MCP_TEST_DATABASE_URL") +pytestmark = pytest.mark.skipif(not ADMIN_URL, reason="no test PostgreSQL configured") +SCHEMA = Path(__file__).parents[1] / "deploy" / "supabase" / "schema.sql" + + +@pytest.fixture +def service_url(): + import psycopg + + with psycopg.connect(ADMIN_URL, autocommit=True) as conn: + conn.execute("DROP SCHEMA IF EXISTS mcp CASCADE") + conn.execute(SCHEMA.read_text()) + conn.execute("ALTER ROLE mcp_service WITH PASSWORD 'service-test-password'") + # Connect as the least-privilege role so grants and RLS policies are exercised. + url = psycopg.conninfo.make_conninfo( + ADMIN_URL, user="mcp_service", password="service-test-password" + ) + return url + + +@pytest.fixture +def pool(service_url): + from deep_agentic_core_mcp.services.postgres_store import connect + + pool = connect(service_url) + yield pool + pool.close() + + +def test_keys_rotate_revoke_and_hash_only_storage(pool): + from deep_agentic_core_mcp.services.postgres_store import PostgresKeyStore + + keys = PostgresKeyStore(pool) + assert keys.ping() + issued = keys.issue("Ada") + user, token = issued["user_id"], issued["api_key"] + assert keys.authenticate(token) == user + assert keys.profile(user) == {"user_id": user, "display_name": "Ada", "status": "active"} + with pool.connection() as conn: + stored = conn.execute("SELECT key_hash FROM mcp.users").fetchone()[0] + assert token not in stored + + rotated = keys.rotate(user, token)["api_key"] + assert keys.authenticate(token) is None + assert keys.authenticate(rotated) == user + with pytest.raises(KeyConflictError): + keys.rotate(user, token) + keys.revoke(user, rotated) + assert keys.authenticate(rotated) is None + with pytest.raises(KeyConflictError): + keys.revoke(user, rotated) + assert keys.authenticate("short") is None + + +def test_import_never_overwrites_existing_user(pool): + import psycopg + + from deep_agentic_core_mcp.services.postgres_store import PostgresKeyStore + + keys = PostgresKeyStore(pool) + keys.import_key("alice", "a" * 40, "Alice") + with pytest.raises(psycopg.errors.UniqueViolation): + keys.import_key("alice", "b" * 40, "Mallory") + assert keys.authenticate("a" * 40) == "alice" + assert keys.authenticate("b" * 40) is None + + +def test_sessions_isolation_rollback_limits_and_expiry(pool): + from deep_agentic_core_mcp.services.postgres_store import PostgresSessionStore + + first, second = PostgresSessionStore(pool), PostgresSessionStore(pool) + assert first.ping() + with first.transaction("alice"): + session.get_session().workflow = {"name": "private"} + with second.transaction("bob"): + assert session.get_session().workflow is None + with second.transaction("alice"): + assert session.get_session().workflow == {"name": "private"} + + with pytest.raises(RuntimeError, match="failed"), first.transaction("alice"): + session.get_session().workflow = {"name": "discard"} + raise RuntimeError("failed") + with pytest.raises(ValueError, match="limit"), first.transaction("alice"): + for i in range(101): + session.get_session(str(i)) + with first.transaction("alice"): + assert session.get_session().workflow == {"name": "private"} + + with pool.connection() as conn: + conn.execute("UPDATE mcp.sessions SET expires_at = now() - interval '1 second'") + with first.transaction("alice"): + assert session.get_session().workflow is None + + +def test_concurrent_calls_for_one_user_are_serialized(pool): + from deep_agentic_core_mcp.services.postgres_store import PostgresSessionStore + + store = PostgresSessionStore(pool) + + def increment(): + with store.transaction("alice"): + state = session.get_session() + count = (state.workflow or {}).get("count", 0) + time.sleep(0.05) + state.workflow = {"count": count + 1} + + threads = [threading.Thread(target=increment) for _ in range(5)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + with store.transaction("alice"): + assert session.get_session().workflow == {"count": 5} + + +def test_signup_limits_are_shared_and_do_not_store_client_ips(pool): + from deep_agentic_core_mcp.services.postgres_store import PostgresSignupLimiter + + first, second = PostgresSignupLimiter(pool), PostgresSignupLimiter(pool) + assert all(first.allow("203.0.113.9") for _ in range(5)) + assert all(second.allow("203.0.113.9") for _ in range(5)) + assert not first.allow("203.0.113.9") + assert second.allow("198.51.100.4") + with pool.connection() as conn: + buckets = [row[0] for row in conn.execute("SELECT bucket FROM mcp.signup_counters")] + assert not any("203.0.113.9" in bucket for bucket in buckets) + conn.execute("UPDATE mcp.signup_counters SET expires_at = now()") + assert first.allow("203.0.113.9") + + +async def test_app_uses_database_for_signup_auth_and_sessions(service_url, monkeypatch): + from deep_agentic_core_mcp import config + from deep_agentic_core_mcp.transport_http import create_app + + monkeypatch.setenv(config.REMOTE_TRANSPORT_ENV, "0") + for name in ("REDIS_URL", "USERS_TABLE", "API_KEYS"): + monkeypatch.delenv("DEEP_AGENTIC_CORE_MCP_" + name, raising=False) + monkeypatch.setenv("DEEP_AGENTIC_CORE_MCP_DATABASE_URL", service_url) + monkeypatch.setenv("DEEP_AGENTIC_CORE_MCP_PUBLIC_URL", "https://mcp.example") + app = create_app(allowed_hosts=["mcp.example"]) + async with app.router.lifespan_context(app): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example") as client: + assert (await client.get("/healthz")).text == "ok" + assert (await client.post("/mcp", json={})).status_code == 401 + created = await client.post( + "/api/signup", + json={"display_name": "Grace"}, + headers={"origin": "https://mcp.example"}, + ) + assert created.status_code == 201 + token = created.json()["api_key"] + headers = { + "authorization": "Bearer " + token, + "accept": "application/json, text/event-stream", + } + + async def call(name, arguments): + reply = await client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": name, "arguments": arguments}, + }, + headers=headers, + ) + assert reply.status_code == 200 + return json.loads(reply.json()["result"]["content"][0]["text"]) + + stored = await call( + "lens.analyze_workflow", + {"artifact": {"name": "grace-run", "start_time": "2026-01-01T00:00:00Z"}}, + ) + assert stored["ok"] is True, stored + # A separate request reads the state back from PostgreSQL. + summary = await call("lens.report_summary", {}) + assert summary["workflow"]["name"] == "grace-run" + account = await client.get( + "/api/account", headers={**headers, "origin": "https://mcp.example"} + ) + assert account.json()["display_name"] == "Grace" From f4a8f113b77e0b6df628b754b5d9eed75869b7b0 Mon Sep 17 00:00:00 2001 From: PRAMOD B N Date: Tue, 20 Oct 2026 02:20:16 -0500 Subject: [PATCH 5/7] deploy(render): store keys and sessions in Supabase instead of Key Value --- CHANGELOG.md | 10 +++++++ docs/remote-hosting.md | 61 +++++++++++++++++++++++++++--------------- render.yaml | 19 +++++-------- 3 files changed, 56 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e53eb5..5faf00b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,18 @@ This project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- PostgreSQL (Supabase) storage for hosted API keys, per-user sessions and + signup limits, selected by `DEEP_AGENTIC_CORE_MCP_DATABASE_URL`. The Render + Blueprint now uses it instead of Render Key Value; `deploy/supabase/schema.sql` + creates the private schema and least-privilege service role. + ### Changed +- Key rotation/revocation conflicts raise `KeyConflictError` for every backend. +- Fix transport and signup test fixtures that leaked remote mode into later tests. + - Roadmap only, decided after the 0.4.0 release: Phase 4 (Unified Workflows) is now complete as of 0.4.0, which its release notes below still call the first slice. The access-containment items (tool-level permissions, revocation at diff --git a/docs/remote-hosting.md b/docs/remote-hosting.md index ad2be2b..870ba6a 100644 --- a/docs/remote-hosting.md +++ b/docs/remote-hosting.md @@ -108,29 +108,46 @@ The same session name is private to each authenticated user. Omit it to use that user's private `default` workflow session. Never put credentials in query parameters or tool arguments. -## Render deployment - -The root `render.yaml` provisions a free Docker web service and a free, -internal-only Key Value instance in Oregon. `deploy/docker/Dockerfile.render` builds from this -repository alone: AgenticLens 0.5.0 comes from PyPI and the canonical AIOS schemas -are downloaded at a pinned Git commit with SHA-256 verification. - -Render's `PORT` and `RENDER_EXTERNAL_HOSTNAME` are used automatically. The Key -Value connection string is injected through the Blueprint. Supply -`DEEP_AGENTIC_CORE_MCP_API_KEYS` as the user-to-secret JSON mapping when applying -it; never commit actual keys. For a custom domain, explicitly set -`DEEP_AGENTIC_CORE_MCP_ALLOWED_HOSTS` to include both the assigned Render hostname -and your custom hostname. Browser clients also need an allowed Origin. - -After these files are pushed to the repository, create the Blueprint at: -https://dashboard.render.com/blueprint/new?repo=https://github.com/DeepAgentLabs/mcp-server +## Render deployment with Supabase storage + +The root `render.yaml` provisions a free Docker web service in Oregon. +`deploy/docker/Dockerfile.render` builds from this repository alone: hosted +sibling packages come from PyPI and the canonical AIOS schemas are downloaded +at a pinned Git commit with SHA-256 verification. + +API keys (SHA-256 digests only), per-user workflow sessions and signup limits +are stored in Supabase PostgreSQL when `DEEP_AGENTIC_CORE_MCP_DATABASE_URL` is +set. Redis is used for sessions instead only if `DEEP_AGENTIC_CORE_MCP_REDIS_URL` +is also set. + +1. In the Supabase SQL editor, run `deploy/supabase/schema.sql`. It creates the + private `mcp` schema (not exposed through the Data API), enables row-level + security, and creates the least-privilege `mcp_service` login role. +2. Set that role's password: `ALTER ROLE mcp_service WITH PASSWORD '';` +3. Build the connection string from the project's **Connect → Transaction + pooler** URI (port 6543; Supabase direct connections are IPv6-only), using + `mcp_service.` as the user and appending `?sslmode=require`. +4. Create the Blueprint at + https://dashboard.render.com/blueprint/new?repo=https://github.com/DeepAgentLabs/mcp-server + and supply `DEEP_AGENTIC_CORE_MCP_DATABASE_URL` and + `DEEP_AGENTIC_CORE_MCP_PUBLIC_URL` (`https://` plus your custom domain, or the + assigned `*.onrender.com` hostname). Never commit either value. + +Render's `PORT` and `RENDER_EXTERNAL_HOSTNAME` are used automatically, and the +hostname in `DEEP_AGENTIC_CORE_MCP_PUBLIC_URL` is added to the allowed hosts. +Users get keys from the signup page at the public URL. Browser clients also +need an allowed Origin. + +For a custom domain, add it under the service's **Settings → Custom Domains** +and create a `CNAME` record from it to the assigned `.onrender.com` +hostname (use `ALIAS`/`ANAME`, or Render's documented `A` record, for an apex +domain). Render issues the TLS certificate once DNS resolves. Check `/healthz`, verify an unauthenticated `/mcp` request returns 401, and test an authenticated tool call before sharing the endpoint with users. The deployed -MCP URL is `https:///mcp`. +MCP URL is `https:///mcp`. -Free web services can spin down when idle, and free Key Value loses data on -restart. This is an initial test deployment; upgrade for an always-available -service and persistent session storage. Automatic deploys are disabled to avoid -publishing later repository changes without an explicit deployment action. -See [Render free-service limits](https://render.com/docs/free). +Free web services spin down when idle; the first request afterwards is slow. +Free Supabase projects pause after a week without activity. Automatic deploys +are disabled to avoid publishing later repository changes without an explicit +deployment action. See [Render free-service limits](https://render.com/docs/free). diff --git a/render.yaml b/render.yaml index e7c157b..c9d9bdc 100644 --- a/render.yaml +++ b/render.yaml @@ -1,4 +1,6 @@ # yaml-language-server: $schema=https://render.com/schema/render.yaml.json +# Keys, sessions and signup limits live in Supabase PostgreSQL; apply +# deploy/supabase/schema.sql there first (see docs/remote-hosting.md). services: - type: web name: deep-agentic-core-mcp @@ -11,18 +13,11 @@ services: healthCheckPath: /healthz autoDeployTrigger: "off" envVars: - - key: DEEP_AGENTIC_CORE_MCP_API_KEYS + # Supabase pooler URI for the mcp_service role, ending in ?sslmode=require + - key: DEEP_AGENTIC_CORE_MCP_DATABASE_URL + sync: false + # https://; enables signup + - key: DEEP_AGENTIC_CORE_MCP_PUBLIC_URL sync: false - - key: DEEP_AGENTIC_CORE_MCP_REDIS_URL - fromService: - name: deep-agentic-core-mcp-sessions - type: keyvalue - property: connectionString - key: DEEP_AGENTIC_CORE_MCP_HTTP_HOST value: 0.0.0.0 - - type: keyvalue - name: deep-agentic-core-mcp-sessions - plan: free - region: oregon - maxmemoryPolicy: noeviction - ipAllowList: [] From e10d533d3db6725d42e68cc81981c26d2b561fad Mon Sep 17 00:00:00 2001 From: PRAMOD B N Date: Tue, 20 Oct 2026 03:46:38 -0500 Subject: [PATCH 6/7] fix(http): size the PostgreSQL pool for concurrency and close it on shutdown A session transaction holds a pool connection for a whole tool call, so the pool now allows max_concurrent_total plus headroom for auth and signup queries. The app owns the pool: lifespan closes it, and so does a failed create_app. --- CHANGELOG.md | 4 +- src/deep_agentic_core_mcp/transport_http.py | 58 +++++++++++++++++---- tests/test_postgres_store.py | 1 + tests/test_transport_http.py | 31 +++++++++++ 4 files changed, 83 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5faf00b..23c1bc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ This project follows [Semantic Versioning](https://semver.org/). - PostgreSQL (Supabase) storage for hosted API keys, per-user sessions and signup limits, selected by `DEEP_AGENTIC_CORE_MCP_DATABASE_URL`. The Render Blueprint now uses it instead of Render Key Value; `deploy/supabase/schema.sql` - creates the private schema and least-privilege service role. + creates the private schema and least-privilege service role. Its connection + pool is sized from the request concurrency limit and closed on shutdown or + failed startup. ### Changed diff --git a/src/deep_agentic_core_mcp/transport_http.py b/src/deep_agentic_core_mcp/transport_http.py index 32045e2..b6e1c10 100644 --- a/src/deep_agentic_core_mcp/transport_http.py +++ b/src/deep_agentic_core_mcp/transport_http.py @@ -57,10 +57,44 @@ def create_app( """ if key_store is not None and api_keys is not None: raise ValueError("Supply only one authentication backend") + if min(requests_per_minute, max_concurrent_per_user, max_concurrent_total) < 1: + raise ValueError("Request and concurrency limits must be positive") database_url = os.environ.get("DEEP_AGENTIC_CORE_MCP_DATABASE_URL") pool: Any = None if database_url and (store is None or (key_store is None and api_keys is None)): - pool = connect(database_url) + # A session transaction holds a connection for a whole tool call, so every + # admitted request needs one, plus headroom for auth and signup queries. + pool = connect(database_url, max_size=max_concurrent_total + 4) + try: + return _build_app( + pool=pool, + api_keys=api_keys, + key_store=key_store, + store=store, + allowed_hosts=allowed_hosts, + allowed_origins=allowed_origins, + requests_per_minute=requests_per_minute, + max_concurrent_per_user=max_concurrent_per_user, + max_concurrent_total=max_concurrent_total, + ) + except BaseException: + if pool is not None: + pool.close() + raise + + +def _build_app( + *, + pool: Any, + api_keys: Mapping[str, str] | None, + key_store: KeyStore | None, + store: SessionStore | None, + allowed_hosts: list[str] | None, + allowed_origins: list[str] | None, + requests_per_minute: int, + max_concurrent_per_user: int, + max_concurrent_total: int, +) -> Starlette: if key_store is None: table = os.environ.get("DEEP_AGENTIC_CORE_MCP_USERS_TABLE") if pool is not None and api_keys is None: @@ -72,8 +106,6 @@ def create_app( api_keys = json.loads(os.environ.get("DEEP_AGENTIC_CORE_MCP_API_KEYS", "{}")) key_store = StaticKeyStore(api_keys) auth_store = key_store - if min(requests_per_minute, max_concurrent_per_user, max_concurrent_total) < 1: - raise ValueError("Request and concurrency limits must be positive") if store is None: url = os.environ.get("DEEP_AGENTIC_CORE_MCP_REDIS_URL") if url: @@ -166,12 +198,16 @@ async def healthz(request: Request) -> PlainTextResponse: @contextlib.asynccontextmanager async def lifespan(app: Starlette) -> AsyncIterator[None]: del app - if not await asyncio.to_thread(session_store.ping): - raise RuntimeError("Session storage is unavailable") - if not await asyncio.to_thread(auth_store.ping): - raise RuntimeError("Authentication storage is unavailable") - async with session_manager.run(): - yield + try: + if not await asyncio.to_thread(session_store.ping): + raise RuntimeError("Session storage is unavailable") + if not await asyncio.to_thread(auth_store.ping): + raise RuntimeError("Authentication storage is unavailable") + async with session_manager.run(): + yield + finally: + if pool is not None: + await asyncio.to_thread(pool.close) endpoint = MCPEndpoint() routes = [Route("/healthz", healthz), Route("/mcp", endpoint), Route("/mcp/", endpoint)] @@ -196,10 +232,12 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: trusted_proxy_hops=int(os.environ.get("DEEP_AGENTIC_CORE_MCP_TRUSTED_PROXY_HOPS", "0")), allowed_origins=allowed_origins, ) - return Starlette( + app = Starlette( routes=routes, lifespan=lifespan, ) + app.state.db_pool = pool + return app def main() -> None: diff --git a/tests/test_postgres_store.py b/tests/test_postgres_store.py index 6d08419..5c1b523 100644 --- a/tests/test_postgres_store.py +++ b/tests/test_postgres_store.py @@ -199,3 +199,4 @@ async def call(name, arguments): "/api/account", headers={**headers, "origin": "https://mcp.example"} ) assert account.json()["display_name"] == "Grace" + assert app.state.db_pool.closed diff --git a/tests/test_transport_http.py b/tests/test_transport_http.py index 790a6f8..a2a029f 100644 --- a/tests/test_transport_http.py +++ b/tests/test_transport_http.py @@ -55,6 +55,37 @@ def test_service_requires_credentials_and_storage(): create_app(api_keys={"alice": "x" * 40, "bob": "x" * 40}) +class FakePool: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + +def test_database_pool_is_sized_for_concurrency_and_closed(monkeypatch): + from deep_agentic_core_mcp import transport_http + + pools = [] + + def fake_connect(url, *, max_size): + pools.append((url, max_size, FakePool())) + return pools[-1][2] + + monkeypatch.setattr(transport_http, "connect", fake_connect) + monkeypatch.setenv("DEEP_AGENTIC_CORE_MCP_DATABASE_URL", "postgresql://db") + monkeypatch.delenv("DEEP_AGENTIC_CORE_MCP_PUBLIC_URL", raising=False) + # Signup without an HTTPS public URL fails after the pool opened: it must be released. + with pytest.raises(ValueError, match="HTTPS public URL"): + create_app(max_concurrent_total=7) + assert pools[0][:2] == ("postgresql://db", 11) + assert pools[0][2].closed + + app = create_app(api_keys=KEYS, allowed_hosts=["testserver"]) + pool = app.state.db_pool + assert pool is pools[1][2] and pools[1][1] == 20 and not pool.closed + + @pytest.mark.asyncio async def test_mixed_case_configured_hostname_accepts_lowercase_proxy_host(): app = create_app( From c51e9c522c6872b71853ee0b92ff760ff3c1e345 Mon Sep 17 00:00:00 2001 From: PRAMOD B N Date: Tue, 20 Oct 2026 03:46:38 -0500 Subject: [PATCH 7/7] docs(hosting): document PostgreSQL as the shared storage alternative to Redis --- docs/remote-hosting.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/remote-hosting.md b/docs/remote-hosting.md index 870ba6a..089f442 100644 --- a/docs/remote-hosting.md +++ b/docs/remote-hosting.md @@ -2,7 +2,8 @@ The stdio package and authenticated HTTP service share tool implementations. HTTP clients connect to `/mcp` with a unique per-user bearer key. Workflow state -is shared across workers through Redis, while MCP transport is stateless and +is shared across workers through Redis or PostgreSQL (e.g. Supabase), while MCP +transport is stateless and requires no load-balancer session affinity. Users cannot inspect other users' workflows by supplying their session names. @@ -10,11 +11,12 @@ workflows by supplying their session names. | Environment variable | Required | Purpose | | --- | --- | --- | -| `DEEP_AGENTIC_CORE_MCP_API_KEYS` | Without DynamoDB | JSON object mapping stable user IDs to unique secrets | +| `DEEP_AGENTIC_CORE_MCP_DATABASE_URL` | Redis or this | PostgreSQL/Supabase URL for keys, signup limits and (without Redis) sessions; apply `deploy/supabase/schema.sql` first | +| `DEEP_AGENTIC_CORE_MCP_API_KEYS` | Without a database or DynamoDB | JSON object mapping stable user IDs to unique secrets | | `DEEP_AGENTIC_CORE_MCP_USERS_TABLE` | AWS signup | DynamoDB user/key table; overrides environment key configuration | -| `DEEP_AGENTIC_CORE_MCP_PUBLIC_URL` | AWS signup | HTTPS origin for connection details and browser origin checks | +| `DEEP_AGENTIC_CORE_MCP_PUBLIC_URL` | With signup | HTTPS origin for connection details and browser origin checks | | `DEEP_AGENTIC_CORE_MCP_TRUSTED_PROXY_HOPS` | No | Trusted suffix position in X-Forwarded-For; AWS sets 2 for its restricted CloudFront/ALB chain | -| `DEEP_AGENTIC_CORE_MCP_REDIS_URL` | Yes | Shared Redis URL; use `rediss://` for TLS | +| `DEEP_AGENTIC_CORE_MCP_REDIS_URL` | This or a database | Shared Redis URL; use `rediss://` for TLS; takes precedence for sessions | | `DEEP_AGENTIC_CORE_MCP_ALLOWED_HOSTS` | For remote hosting | Comma-separated Host values, e.g. `mcp.example.com` | | `DEEP_AGENTIC_CORE_MCP_ALLOWED_ORIGINS` | For browser clients | Exact permitted origins, e.g. `https://client.example.com`; absent Origin is permitted | | `DEEP_AGENTIC_CORE_MCP_HTTP_HOST` | No | Bind address; default `127.0.0.1`, container uses `0.0.0.0` | @@ -34,7 +36,7 @@ random secrets of at least 32 characters; generate with `secrets.token_urlsafe(3 Keep user IDs stable during key rotation to preserve session ownership. Replace credentials and restart all workers; during rolling rotation, old and new workers otherwise accept different keys. Removal revokes future access once all -workers restart. Existing in-flight calls may finish. Redis state expires after +workers restart. Existing in-flight calls may finish. Session state expires after one hour of inactivity, measured from a completed tool call. Sharing a key means sharing a user identity.