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
9 changes: 5 additions & 4 deletions agent/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -106,21 +106,22 @@ COPY agent/distribution.yaml /opt/deepsql-agent/distribution.yaml
COPY mcp /opt/deepsql-agent/mcp
COPY scripts/local-agent-provisioner.py /opt/deepsql-agent/provisioner.py
COPY agent/docker-entrypoint.sh /opt/deepsql-agent/docker-entrypoint.sh
COPY agent/patches /opt/deepsql-agent/patches

RUN chmod +x /opt/deepsql-agent/docker-entrypoint.sh \
&& cd /opt/deepsql-agent/mcp && npm install --omit=dev --ignore-scripts \
&& mkdir -p /var/lib/deepsql-agent/logs /var/lib/deepsql-agent/profiles \
# Apply DeepSQL Agent branding to the API UI (idempotent overlay).
&& bash /opt/deepsql-agent/webui-overlay/apply-overlay.sh /opt/deepsql-agent/api || true
&& bash /opt/deepsql-agent/webui-overlay/apply-overlay.sh /opt/deepsql-agent/api || true \
&& python3 /opt/deepsql-agent/patches/stamp-deepsql-mcp-actor.py /opt/deepsql-agent/runtime/tools/mcp_tool.py

# Expose the agent API (:8787) and the secret-gated provisioner (:8788).
EXPOSE 8787 8788

VOLUME ["/var/lib/deepsql-agent"]

# Provisioner /health is unauthenticated and always 200 when the process is up.
# The API on :8787 may require a session, so we don't use it for the healthcheck.
# Agent API liveness on :8787; provisioner /health on :8788 is a separate check.
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=5 \
CMD curl -fsS http://127.0.0.1:8788/health >/dev/null || exit 1
CMD curl -fsS http://127.0.0.1:8787/health >/dev/null || exit 1

ENTRYPOINT ["/opt/deepsql-agent/docker-entrypoint.sh"]
8 changes: 5 additions & 3 deletions agent/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,15 @@ dashboard.pop("password_hash", None)
dashboard.pop("password", None)

existing_env = ((cfg.get("mcp_servers") or {}).get("deepsql") or {}).get("env") or {}
profiles_home = f"{os.environ['AGENT_HOME']}/profiles"
provisioner_port = os.environ.get("DEEPSQL_AGENT_PROVISIONER_PORT", "8788")
mcp_env = {
"DEEPSQL_API_BASE_URL": os.environ["DEEPSQL_API_BASE_URL"],
"DEEPSQL_MCP_USER_ID": existing_env.get("DEEPSQL_MCP_USER_ID", "deepsql-agent"),
"DEEPSQL_PROFILES_HOME": profiles_home,
"DEEPSQL_ACTOR_PROFILE_FILE": f"{os.environ['AGENT_HOME']}/active-mcp-profile",
"DEEPSQL_PROVISIONER_REFRESH_URL": f"http://127.0.0.1:{provisioner_port}/refresh",
"DEEPSQL_MCP_PROJECT_ID": existing_env.get("DEEPSQL_MCP_PROJECT_ID", "deepsql-agent"),
}
if existing_env.get("DEEPSQL_AUTH_TOKEN"):
mcp_env["DEEPSQL_AUTH_TOKEN"] = existing_env["DEEPSQL_AUTH_TOKEN"]

cfg.setdefault("mcp_servers", {})["deepsql"] = {
"command": "node",
Expand Down
80 changes: 80 additions & 0 deletions agent/patches/stamp-deepsql-mcp-actor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Patch Hermes mcp_tool.py to stamp the active profile before each deepsql MCP call.

The DeepSQL MCP stdio server is process-global; it reads
DEEPSQL_ACTOR_PROFILE_FILE on every tool call to pick the correct
profiles/u-<user>/deepsql.token. Hermes must write that file immediately
before invoking tools on server ``deepsql``.
"""
from __future__ import annotations

import sys
from pathlib import Path

MARKER = "deepsql_stamp_active_mcp_profile"
NEEDLE = " gate_error = _trust_gate_check(server_name, tool_name)"
INSERT = f""" gate_error = _trust_gate_check(server_name, tool_name)
# {MARKER}: per-request profile for shared DeepSQL MCP auth
if server_name == "deepsql":
_stamp_deepsql_active_mcp_profile()
"""


def patch(path: Path) -> int:
text = path.read_text(encoding="utf-8")
if MARKER in text:
print(f"already patched: {path}")
return 0
if NEEDLE not in text:
print(f"ERROR: needle not found in {path}", file=sys.stderr)
return 1

helper = '''

def _stamp_deepsql_active_mcp_profile() -> None:
"""Write the active Hermes profile id for the shared DeepSQL MCP subprocess."""
import os
from pathlib import Path as _Path

actor_file = (os.environ.get("DEEPSQL_ACTOR_PROFILE_FILE") or "").strip()
if not actor_file:
return
profile = None
try:
from api.profiles import get_active_profile_name as _webui_active
profile = _webui_active()
except Exception:
try:
from hermes_cli.profiles import get_active_profile_name as _cli_active
profile = _cli_active()
except Exception:
profile = None
if not profile or profile == "default":
return
try:
p = _Path(actor_file)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(str(profile).strip() + "\\n", encoding="utf-8")
except Exception:
pass
'''
text = text.replace(NEEDLE, INSERT, 1)
anchor = "def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):"
if anchor not in text:
print(f"ERROR: handler anchor not found in {path}", file=sys.stderr)
return 1
text = text.replace(anchor, helper + "\n" + anchor, 1)
path.write_text(text, encoding="utf-8")
print(f"patched {path}")
return 0


def main() -> int:
if len(sys.argv) != 2:
print("usage: stamp-deepsql-mcp-actor.py <path/to/mcp_tool.py>", file=sys.stderr)
return 2
return patch(Path(sys.argv[1]))


if __name__ == "__main__":
raise SystemExit(main())
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
// Internal test-suite token issuance — validated by pre-shared
// INTERNAL_TEST_TOKEN secret, not by user credentials
"/auth/internal/token",
"/agent/internal/mcp-token",
// Public dashboard sharing — the share token is the authorization;
// these endpoints only resolve while the dashboard is published,
// and every query is server-enforced read-only.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

import java.util.HashMap;
import java.util.Map;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;

/**
* Bootstrap endpoint for the native "Agent" chat tab. Resolves the current
Expand All @@ -32,6 +34,42 @@ public class AgentBridgeController {
@Value("${security.cookie.name:auth_token}")
private String cookieName;

@Value("${agent.provision-secret:}")
private String provisionSecret;

/**
* Secret-gated token mint for the agent container provisioner ({@code POST /refresh}).
* Not for browser or MCP clients — only the loopback provisioner on :8788.
*/
@PostMapping("/internal/mcp-token")
public ResponseEntity<Map<String, Object>> mintProvisionerToken(
@RequestHeader(value = "X-Provision-Secret", required = false) String providedSecret,
@RequestBody(required = false) Map<String, Object> body) {
if (provisionSecret == null || provisionSecret.isBlank()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(Map.of(
"message", "Agent provisioning is not configured"
));
}
if (providedSecret == null || !MessageDigest.isEqual(
provisionSecret.getBytes(StandardCharsets.UTF_8),
providedSecret.getBytes(StandardCharsets.UTF_8))) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of(
"message", "Unauthorized"
));
}
String user = body == null ? null : asString(body.get("user"));
if (user == null || user.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "user is required"));
}
String token = agentBridgeService.mintAgentTokenForProvisioner(user.trim());
if (token == null || token.isBlank()) {
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(Map.of(
"message", "Could not mint MCP token for " + user
));
}
return ResponseEntity.ok(Map.of("token", token, "user", user.trim()));
}

@PostMapping("/session")
public ResponseEntity<Map<String, Object>> session(
@RequestBody(required = false) Map<String, Object> body,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,9 @@ public ProfileBootstrap ensureProfile(String username, String authToken, String
}
agentToken = authToken == null ? "" : authToken;
}
// The provisioner also mirrors this token onto every profile
// `deepsql.token` the already-running Hermes MCP subprocess watches.
// Profile switch does not respawn MCP; without that mirror, View as
// Agent chats keep the admin credential and skip policy.
// Per-user credentials live only under profiles/u-<user>/deepsql.token.
// The long-lived MCP subprocess resolves the active profile on each tool
// call (DEEPSQL_ACTOR_PROFILE_FILE) — never a shared root token file.
callProvisioner(username, profile, agentToken, connectionId);
return new ProfileBootstrap(profile, agentToken);
}
Expand Down Expand Up @@ -374,6 +373,17 @@ private String mintAgentToken(String username) {
}
}

/**
* Mint endpoint for the agent-side provisioner ({@code POST /refresh} on :8788).
* Returns {@code null} when the user is unknown or minting fails.
*/
public String mintAgentTokenForProvisioner(String username) {
if (username == null || username.isBlank()) {
return null;
}
return mintAgentToken(username);
}

/** Mint a dedicated CHANNEL token (Slack, etc.); null if minting failed. */
private String mintChannelToken(String username) {
try {
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ services:
volumes:
- dba-agent-agent:/var/lib/deepsql-agent
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8788/health || exit 1"]
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8787/health || exit 1"]
interval: 30s
timeout: 5s
retries: 5
Expand Down
Loading
Loading