Skip to content

Commit 8ccb970

Browse files
committed
Merge branch 'fix/agent-mcp-sdk-pin'
Fixes the DeepSQL agent, which could not complete a turn, plus the guards that let it ship broken. Root cause: setup-agent.sh installed the Python MCP SDK as unpinned 'mcp>=1.0'. SDK 2.0.0 renamed CallToolResult.isError to .is_error, while hermes-agent 0.20.0 still reads .isError, so every DeepSQL tool call raised AttributeError and the UI showed only 'The agent run ended early.' Also fixed: an SDK check and an e2e gate that both reported success over a dead agent, install.sh aborting silently at EOF, 'deepsql login' ignoring the pinned default profile, and the CLI rendering an unreachable server as an empty account.
2 parents c9f6337 + 4fe918a commit 8ccb970

12 files changed

Lines changed: 434 additions & 45 deletions

File tree

.env.example

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,19 @@ EMBEDDING_FAIL_OPEN=false
150150
# OPTIONAL — DeepSQL Agent runtime (chat TUI + the web Agent tab)
151151
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
152152
# The agent surfaces — `deepsql agent` and the Agent tab — are served by a separate
153-
# runtime, not by the backend. The defaults below are Compose service names, so on a
154-
# native (non-Compose) run they resolve to nothing and every agent turn fails with
155-
# "cannot reach the agent runtime at http://deepsql-agent:8787". Point them at wherever
156-
# the runtime actually listens. Everything else in DeepSQL works without them.
153+
# runtime, not by the backend. There is no deepsql-agent container in this stack:
154+
# setup-agent.sh installs Hermes on the *host*, listening on :8787. That is why the
155+
# chat default reaches out of the container instead of across the Compose network.
156+
# Everything else in DeepSQL works without any of this.
157157
#
158-
# AGENT_WEBUI_URL where the agent runtime serves its API (default http://deepsql-agent:8787)
159-
# AGENT_PROVISIONER_URL per-user profile provisioning endpoint (default http://deepsql-agent:8788/provision)
158+
# AGENT_WEBUI_URL where the agent runtime serves its API. Default
159+
# http://host.docker.internal:8787 (AgentChatClient.java:58),
160+
# which is right under Compose — but a native backend run has no
161+
# such host, so there it must be set to http://127.0.0.1:8787.
162+
# AGENT_PROVISIONER_URL per-user profile provisioning endpoint. Default
163+
# http://deepsql-agent:8788/provision (AgentBridgeService.java:65)
164+
# still names the container that does not exist here, so this one
165+
# must be set explicitly — under Compose and natively alike.
160166
# AGENT_PROVISION_SECRET shared secret for the above. Unset, the backend logs
161167
# "agent.provision-secret is unset — skipping" and never creates
162168
# the u-<user> profile, so the agent has no identity to run as.

agent/distribution.yaml

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,25 @@
66
name: deepsql-agent
77
version: 0.1.0
88
description: "DeepSQL DBA agent — grounded, read-only database assistant over the DeepSQL MCP tools (BI queries, schema exploration, index advice, slow-query optimization, workload analysis)."
9-
# The upper bound is evidence, not caution: verified on 2026-08-06 against upstream
10-
# v0.20.0 (2026.8.3), where every agent API call answers
11-
# 401 {"detail":"Unauthorized","reason":"no_cookie"}. That release requires a login
12-
# cookie on /api/session/new and friends, and AgentChatClient authenticates in no way
13-
# at all — the Compose deployment relies on a reverse proxy injecting
14-
# X-DeepSQL-User / X-DeepSQL-Token, which does not exist on a native run.
9+
# 2026-08-07: the previous ceiling of <0.20.0 was WRONG and has been removed.
10+
# It claimed 0.20.0 was verified-incompatible because every API call answered
11+
# 401 {"detail":"Unauthorized","reason":"no_cookie"}. That verification was invalid:
12+
# it came from a hand-rolled native run started with `hermes serve` bound to
13+
# 127.0.0.1 rather than `hermes webui` as setup-agent.sh actually starts it, and the
14+
# 401s came from the tester's own curl, not from the backend.
1515
#
16-
# The floor stays at 0.12.0 because nothing below it has been tested; it is not a
17-
# claim that 0.12.0 works. Raise the ceiling only once the backend authenticates to
18-
# the runtime and a real turn has been seen to complete.
19-
hermes_requires: ">=0.12.0,<0.20.0"
16+
# Re-verified on 2026-08-07 against hermes-agent 0.20.0 as installed by
17+
# scripts/self-host/setup-agent.sh: profile switch, session creation, SSE streaming
18+
# and MCP tool calls all succeed and a full turn completes
19+
# (e2e-agent-check.py -> AGENT_OK True, DASH_OK True).
20+
#
21+
# What actually broke the agent was never the hermes version — it was the unpinned
22+
# Python MCP SDK. 2.0.0 renamed CallToolResult.isError to .is_error, so every tool
23+
# call raised AttributeError. That is now pinned in setup-agent.sh (mcp>=1.0,<2).
24+
#
25+
# The floor stays at 0.12.0 because nothing below it has been tested; that is not a
26+
# claim that 0.12.0 works.
27+
hermes_requires: ">=0.12.0"
2028
author: "DeepSQL"
2129
license: "proprietary"
2230

mcp/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@deepsql/mcp",
3-
"version": "0.26.0",
3+
"version": "0.26.1",
44
"description": "DeepSQL CLI, DBA Agent (thin client), and stdio MCP server for self-hosted deployments",
55
"bin": {
66
"deepsql": "bin/deepsql.js",

mcp/src/commands/_agent_intro.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ function renderIntro(
8585
connections = [],
8686
suggestions = [],
8787
recommendationCount = 0,
88+
// Non-null when the connection list could not be fetched at all. Kept
89+
// distinct from "connections === []" so an unreachable server is never
90+
// rendered as an empty account.
91+
unreachable = null,
92+
baseUrl = null,
8893
} = {}
8994
) {
9095
const out = [""];
@@ -102,7 +107,18 @@ function renderIntro(
102107

103108
// Connections the token can see (frictionless onboarding: guide to add one if none).
104109
out.push("");
105-
if (connections.length === 0) {
110+
if (unreachable) {
111+
// Never render the onboarding empty-state for a server we could not reach.
112+
// "No databases connected yet → deepsql connections add" tells someone whose
113+
// connections are perfectly fine to go create another one, and hides the fact
114+
// that the CLI is pointed somewhere dead. Name the host: the usual cause is
115+
// the saved default pointing somewhere other than the last `deepsql login`.
116+
out.push(" " + c(AMBER, "Could not reach your DeepSQL server", useColor));
117+
if (baseUrl) out.push(" " + dim(baseUrl, useColor));
118+
out.push(" " + dim(unreachable, useColor));
119+
out.push(" " + dim("→ ", useColor) + "deepsql config show"
120+
+ dim(" (check which host is the default)", useColor));
121+
} else if (connections.length === 0) {
106122
out.push(" " + c(PURPLE, "No databases connected yet", useColor));
107123
out.push(" " + dim("→ ", useColor) + "deepsql connections add");
108124
} else {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"use strict";
2+
3+
const test = require("node:test");
4+
const assert = require("node:assert/strict");
5+
6+
const { renderIntro } = require("./_agent_intro");
7+
const { loadIntroData } = require("./_agent_status");
8+
9+
function capture() {
10+
let buf = "";
11+
return { write: (s) => { buf += s; }, get: () => buf };
12+
}
13+
14+
function intro(opts) {
15+
const out = capture();
16+
renderIntro(out, { useColor: false, ...opts });
17+
return out.get();
18+
}
19+
20+
// ─── the reported bug ─────────────────────────────────────────────────────
21+
//
22+
// After logging in to a second host while the saved default still pointed at a
23+
// dead one, the agent intro said "No databases connected yet → deepsql
24+
// connections add" — advising the user to create a connection they already had,
25+
// and hiding the fact that the CLI was pointed at a server that wasn't running.
26+
// `deepsql connections list` reported the network error correctly the whole
27+
// time; only the intro swallowed it.
28+
29+
test("an unreachable server is NOT rendered as an empty account", () => {
30+
const text = intro({
31+
connections: [],
32+
unreachable: "Network error contacting http://localhost:8082/api/connections: fetch failed",
33+
baseUrl: "http://localhost:8082",
34+
});
35+
assert.doesNotMatch(
36+
text,
37+
/No databases connected yet/,
38+
"must not tell the user to add a connection when the server was never reached",
39+
);
40+
assert.doesNotMatch(text, /deepsql connections add/);
41+
assert.match(text, /Could not reach your DeepSQL server/);
42+
// Name the host — the usual cause is the default pointing somewhere else.
43+
assert.match(text, /http:\/\/localhost:8082/);
44+
// And carry the underlying reason rather than inventing a friendlier one.
45+
assert.match(text, /fetch failed/);
46+
});
47+
48+
test("a genuinely empty account still gets the onboarding hint", () => {
49+
const text = intro({ connections: [], unreachable: null });
50+
assert.match(text, /No databases connected yet/);
51+
assert.match(text, /deepsql connections add/);
52+
assert.doesNotMatch(text, /Could not reach/);
53+
});
54+
55+
test("connections are listed when the server is reachable", () => {
56+
const text = intro({
57+
connections: [
58+
{ name: "Self-Host Vault Postgres", dbType: "postgres", canManage: true },
59+
],
60+
unreachable: null,
61+
});
62+
assert.match(text, /Connections \(1\)/);
63+
assert.match(text, /Self-Host Vault Postgres/);
64+
assert.doesNotMatch(text, /Could not reach/);
65+
assert.doesNotMatch(text, /No databases connected yet/);
66+
});
67+
68+
test("renderIntro without the new fields behaves as before", () => {
69+
// Callers that predate `unreachable` must be unaffected.
70+
const text = intro({ connections: [] });
71+
assert.match(text, /No databases connected yet/);
72+
});
73+
74+
// ─── loadIntroData: report the failure instead of swallowing it ───────────
75+
76+
test("loadIntroData reports an unreachable server rather than empty connections", async () => {
77+
// A session pointed at a port nothing is listening on. No stubbing: this is
78+
// the real client path, which is what silently returned [] before.
79+
const session = { baseUrl: "http://127.0.0.1:1", token: "t" };
80+
const data = await loadIntroData(session, { timeoutMs: 1500 });
81+
assert.deepEqual(data.connections, []);
82+
assert.ok(
83+
data.unreachable,
84+
"a connection-list failure must be reported, not swallowed into an empty list",
85+
);
86+
assert.equal(typeof data.unreachable, "string");
87+
});

mcp/src/commands/_agent_status.js

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,35 @@ async function checkConnection(session, c) {
6161

6262
// Returns { connections: [{name,dbType,canManage}], suggestions: [{conn,text,fix}] }.
6363
async function loadIntroData(session, { timeoutMs = 2500 } = {}) {
64-
const data = { connections: [], suggestions: [], recommendationCount: 0 };
64+
const data = { connections: [], suggestions: [], recommendationCount: 0, unreachable: null };
65+
66+
// The connection list is the one fetch whose failure must NOT be swallowed.
67+
// It used to share a single catch with the suggestions below, so a server that
68+
// could not be reached at all left connections empty and the intro rendered
69+
// "No databases connected yet → deepsql connections add" — telling the user to
70+
// add a connection they already had. An unreachable host was indistinguishable
71+
// from an empty account. Reported after a login to a second host while the
72+
// saved default still pointed at a dead one: `deepsql connections list` said
73+
// "Network error contacting http://…:8082/api/connections" while the agent
74+
// intro cheerfully reported no databases.
75+
let list;
76+
try {
77+
list = await withTimeout(listConnections(session), timeoutMs);
78+
} catch (err) {
79+
data.unreachable = (err && err.message) ? err.message : String(err);
80+
return data;
81+
}
82+
83+
data.connections = (list || []).map((c) => ({
84+
id: c.id,
85+
name: c.connectionName || c.name || c.id,
86+
dbType: c.dbType || "",
87+
canManage: !!c.canManageConfig,
88+
}));
89+
90+
// Suggestions really are decoration — a slow or older backend missing these
91+
// endpoints should degrade quietly rather than block the REPL.
6592
try {
66-
const list = await withTimeout(listConnections(session), timeoutMs);
67-
data.connections = (list || []).map((c) => ({
68-
id: c.id,
69-
name: c.connectionName || c.name || c.id,
70-
dbType: c.dbType || "",
71-
canManage: !!c.canManageConfig,
72-
}));
7393
// Status/suggestions only for connections this user can configure
7494
// (admin-level), capped so a workspace with many connections doesn't fan
7595
// out at startup.
@@ -85,7 +105,7 @@ async function loadIntroData(session, { timeoutMs = 2500 } = {}) {
85105
data.recommendationCount = checks.reduce((n, r) => n + (r.recCount || 0), 0);
86106
}
87107
} catch {
88-
/* degrade gracefully — render whatever we have */
108+
/* decoration only — render whatever we have */
89109
}
90110
return data;
91111
}

mcp/src/commands/agent.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ async function run(opts, io = {}) {
127127
connections: introData.connections,
128128
suggestions: introData.suggestions,
129129
recommendationCount: introData.recommendationCount,
130+
// Surface an unreachable server instead of an empty-account banner.
131+
unreachable: introData.unreachable,
132+
baseUrl: session.baseUrl,
130133
});
131134
}
132135
// conversationId stays null on the first turn so the server resumes your most

mcp/src/commands/login.js

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,26 @@ function resolveLoginBaseUrl(opts, { stderr = process.stderr } = {}) {
4848
);
4949
return only;
5050
}
51-
// Multiple profiles — refuse to guess. List them so the user can pick.
51+
// A default the user pinned with `deepsql config set-default` is their stated
52+
// choice, not a guess, so honour it. This branch was missing: the error below
53+
// has always advertised set-default as the remedy, but nothing read
54+
// state.default, so following that advice produced the identical error again
55+
// and left no way out except passing --url on every login.
56+
//
57+
// Require the default to still name a saved profile. set-default writes
58+
// whatever it is given, and profiles can be removed by editing auth.json;
59+
// resolving to a host with no saved profile would only fail later with a
60+
// worse message.
61+
const pinned = state.default && urls.includes(state.default) ? state.default : null;
62+
if (pinned) {
63+
stderr.write(
64+
`[deepsql] Using default profile: ${pinned}. Pass --url to log in against a different host.\n`,
65+
);
66+
return pinned;
67+
}
68+
69+
// Multiple profiles and nothing pinned — refuse to guess. List them so the
70+
// user can pick.
5271
throw new Error(
5372
`Multiple saved DeepSQL profiles. Pass --url <host> to choose one:\n - ${urls.join("\n - ")}\n`
5473
+ `Or run \`deepsql config set-default <url>\` to pin one as the default for future logins.`,
@@ -115,6 +134,23 @@ async function run(opts, { stderr = process.stderr, stdout = process.stdout } =
115134

116135
stdout.write(`Authorized as ${issued.username} at ${baseUrl}\n`);
117136
stdout.write(`Token saved to ${store.authFilePath()}\n`);
137+
138+
// Saving a profile does not make it active: store.setProfile only adopts a new
139+
// default when there is none. So logging into a second host leaves bare
140+
// `deepsql` pointed at the old one — and if that host is down, the agent intro
141+
// reported "No databases connected yet" for a server it never reached, which
142+
// reads as "your login didn't work".
143+
//
144+
// Deliberately NOT switching automatically: silently redirecting a DBA tool at
145+
// a different database is a worse failure than one extra line of output. Say
146+
// what is active and give the exact command.
147+
const active = store.defaultBaseUrl();
148+
if (active && active !== baseUrl) {
149+
stdout.write(
150+
`\nNote: bare \`deepsql\` still uses ${active}.\n`
151+
+ ` Make this host the default: deepsql config set-default ${baseUrl}\n`,
152+
);
153+
}
118154
}
119155

120156
module.exports = { run, resolveLoginBaseUrl };

0 commit comments

Comments
 (0)