Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,20 @@ 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. Its connection
pool is sized from the request concurrency limit and closed on shutdown or
failed startup.

### 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
Expand Down
50 changes: 50 additions & 0 deletions deploy/supabase/schema.sql
Original file line number Diff line number Diff line change
@@ -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 '<generated secret>';
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);
73 changes: 46 additions & 27 deletions docs/remote-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@

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.

## Configuration

| 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` |
Expand All @@ -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.

Expand Down Expand Up @@ -108,29 +110,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.
Comment on lines +120 to +123

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c51e9c5: the table now lists DEEP_AGENTIC_CORE_MCP_DATABASE_URL, marks Redis and the database as alternatives, and the intro and expiry text cover both backends.


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 '<secret>';`
3. Build the connection string from the project's **Connect → Transaction
pooler** URI (port 6543; Supabase direct connections are IPv6-only), using
`mcp_service.<project-ref>` 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 `<service>.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://<assigned-hostname>/mcp`.
MCP URL is `https://<public hostname>/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).
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 7 additions & 12 deletions render.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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://<custom domain or assigned onrender.com hostname>; 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: []
30 changes: 29 additions & 1 deletion src/deep_agentic_core_mcp/services/api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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=[
{
Expand All @@ -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},
Expand Down
Loading
Loading