Add workspace_db_query MCP tool for reading telemetry
This commit is contained in:
@@ -831,6 +831,17 @@ After this returns, ALWAYS call apps_deploy { uuid } to regenerate the live Trae
|
|||||||
|
|
||||||
// ── Databases ─────────────────────────────────────────────────────────────
|
// ── Databases ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "workspace_db_query",
|
||||||
|
description: "Run a read-only SQL query against the workspace's main production/telemetry database (the one powering Next.js + Telemetry). ONLY USE THIS IF THE USER ASKS FOR LOGS OR TELEMETRY USAGE DATA.",
|
||||||
|
parameters: {
|
||||||
|
type: "OBJECT",
|
||||||
|
properties: {
|
||||||
|
sql: { type: "STRING", description: "The SQL query to execute." },
|
||||||
|
},
|
||||||
|
required: ["sql"],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "databases_list",
|
name: "databases_list",
|
||||||
description:
|
description:
|
||||||
@@ -896,6 +907,30 @@ After this returns, ALWAYS call apps_deploy { uuid } to regenerate the live Trae
|
|||||||
required: ["uuid", "patchJson"],
|
required: ["uuid", "patchJson"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "databases_logs",
|
||||||
|
description: "Get runtime logs from a running database cluster. Use this to diagnose database crashes or connection issues.",
|
||||||
|
parameters: {
|
||||||
|
type: "OBJECT",
|
||||||
|
properties: {
|
||||||
|
uuid: { type: "STRING", description: "The UUID of the database cluster." },
|
||||||
|
lines: { type: "INTEGER", description: "Number of lines to fetch. Default is 100." },
|
||||||
|
},
|
||||||
|
required: ["uuid"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deployments_logs",
|
||||||
|
description: "Get build/deployment logs for a specific application deployment (e.g. Next.js build errors).",
|
||||||
|
parameters: {
|
||||||
|
type: "OBJECT",
|
||||||
|
properties: {
|
||||||
|
uuid: { type: "STRING", description: "The UUID of the deployment." },
|
||||||
|
lines: { type: "INTEGER", description: "Number of lines to fetch. Default is 100." },
|
||||||
|
},
|
||||||
|
required: ["uuid"],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "databases_delete",
|
name: "databases_delete",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ export async function GET() {
|
|||||||
"apps.envs.list",
|
"apps.envs.list",
|
||||||
"apps.envs.upsert",
|
"apps.envs.upsert",
|
||||||
"apps.envs.delete",
|
"apps.envs.delete",
|
||||||
|
"workspace.db_query",
|
||||||
"databases.list",
|
"databases.list",
|
||||||
"databases.create",
|
"databases.create",
|
||||||
"databases.get",
|
"databases.get",
|
||||||
@@ -366,6 +367,8 @@ export async function POST(request: Request) {
|
|||||||
case "apps.templates.search":
|
case "apps.templates.search":
|
||||||
return await toolAppsTemplatesSearch(params);
|
return await toolAppsTemplatesSearch(params);
|
||||||
|
|
||||||
|
case "workspace.db_query":
|
||||||
|
return await toolWorkspaceDbQuery(principal, params);
|
||||||
case "databases.list":
|
case "databases.list":
|
||||||
return await toolDatabasesList(principal);
|
return await toolDatabasesList(principal);
|
||||||
case "databases.create":
|
case "databases.create":
|
||||||
@@ -3255,6 +3258,41 @@ const DB_TYPES: readonly CoolifyDatabaseType[] = [
|
|||||||
"clickhouse",
|
"clickhouse",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
async function toolWorkspaceDbQuery(
|
||||||
|
principal: Principal,
|
||||||
|
params: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
const { sql } = params;
|
||||||
|
if (typeof sql !== "string") {
|
||||||
|
return NextResponse.json({ error: "Missing 'sql' string parameter" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety check: Prevent modifying the database
|
||||||
|
const upperSql = sql.toUpperCase().trim();
|
||||||
|
if (
|
||||||
|
upperSql.startsWith("INSERT") ||
|
||||||
|
upperSql.startsWith("UPDATE") ||
|
||||||
|
upperSql.startsWith("DELETE") ||
|
||||||
|
upperSql.startsWith("DROP") ||
|
||||||
|
upperSql.startsWith("ALTER") ||
|
||||||
|
upperSql.startsWith("TRUNCATE") ||
|
||||||
|
upperSql.startsWith("GRANT") ||
|
||||||
|
upperSql.startsWith("REVOKE")
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: "Only SELECT queries are allowed." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await query(sql, []);
|
||||||
|
return NextResponse.json({ result: result.slice(0, 100) }); // Limit to 100 rows for safety
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: String(err) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toolDatabasesList(principal: Principal) {
|
async function toolDatabasesList(principal: Principal) {
|
||||||
const projectUuid = requireCoolifyProject(principal);
|
const projectUuid = requireCoolifyProject(principal);
|
||||||
if (projectUuid instanceof NextResponse) return projectUuid;
|
if (projectUuid instanceof NextResponse) return projectUuid;
|
||||||
|
|||||||
@@ -640,7 +640,7 @@ function Timeline({ entries, isActiveStream }: { entries: TimelineEntry[], isAct
|
|||||||
{items.map((item, i) => {
|
{items.map((item, i) => {
|
||||||
const isLast = i === items.length - 1;
|
const isLast = i === items.length - 1;
|
||||||
if (item.kind === "thought") {
|
if (item.kind === "thought") {
|
||||||
return <TimelineThought key={i} text={item.text} isStreaming={isActiveStream && isLast} />;
|
return <TimelineText key={i} text={item.text} isStreaming={isActiveStream && isLast} />;
|
||||||
}
|
}
|
||||||
if (item.kind === "text") {
|
if (item.kind === "text") {
|
||||||
return <TimelineText key={i} text={item.text} isStreaming={isActiveStream && isLast} />;
|
return <TimelineText key={i} text={item.text} isStreaming={isActiveStream && isLast} />;
|
||||||
@@ -665,92 +665,6 @@ function Timeline({ entries, isActiveStream }: { entries: TimelineEntry[], isAct
|
|||||||
* bubble so each round of multi-tool-loop output reads as a discrete
|
* bubble so each round of multi-tool-loop output reads as a discrete
|
||||||
* step instead of concatenating into a wall of text.
|
* step instead of concatenating into a wall of text.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function TimelineThought({ text, isStreaming }: { text: string; isStreaming?: boolean }) {
|
|
||||||
const [expanded, setExpanded] = React.useState(true);
|
|
||||||
const textLenRef = React.useRef(text.length);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
// If not streaming, auto-collapse after a short delay so the user isn't stuck with huge thinking blocks
|
|
||||||
if (!isStreaming) {
|
|
||||||
const t = setTimeout(() => setExpanded(false), 500);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}
|
|
||||||
}, [isStreaming]);
|
|
||||||
|
|
||||||
const proseWrap: React.CSSProperties = {
|
|
||||||
overflowWrap: "anywhere",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
minWidth: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
margin: "12px 0",
|
|
||||||
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded((v) => !v)}
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
background: "#f4f4f5",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: "999px",
|
|
||||||
padding: "4px 10px",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: "#52525b",
|
|
||||||
fontWeight: 500,
|
|
||||||
transition: "all 0.2s ease",
|
|
||||||
boxShadow: "0 1px 2px rgba(0,0,0,0.02)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Sparkles
|
|
||||||
size={14}
|
|
||||||
className={isStreaming ? "animate-pulse" : ""}
|
|
||||||
style={{ color: "#8b5cf6" }}
|
|
||||||
/>
|
|
||||||
<span className={isStreaming ? "animate-pulse" : ""} style={{ transition: "opacity 0.2s ease" }}>
|
|
||||||
{isStreaming ? "Thinking..." : "Thought Process"}
|
|
||||||
</span>
|
|
||||||
<ChevronRight
|
|
||||||
size={14}
|
|
||||||
style={{
|
|
||||||
transform: expanded ? "rotate(90deg)" : "rotate(0deg)",
|
|
||||||
transition: "transform 0.2s ease",
|
|
||||||
color: "#a1a1aa"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{expanded && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 8,
|
|
||||||
padding: "10px 14px",
|
|
||||||
borderLeft: "2px solid #c4b5fd",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
color: "#52525b",
|
|
||||||
background: "linear-gradient(to right, #f5f3ff 0%, transparent 100%)",
|
|
||||||
borderRadius: "0 8px 8px 0",
|
|
||||||
...proseWrap,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={proseWrap}
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: renderMarkdown(stripRawToolLogs(text)) + (isStreaming ? `<span class="animate-pulse" style="display:inline-block; width:6px; height:13px; background-color:#9ca3af; vertical-align:-1px; margin-left:2px; border-radius:1px;"></span>` : ""),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TimelineText({ text, isStreaming }: { text: string; isStreaming?: boolean }) {
|
function TimelineText({ text, isStreaming }: { text: string; isStreaming?: boolean }) {
|
||||||
const proseWrap: React.CSSProperties = {
|
const proseWrap: React.CSSProperties = {
|
||||||
overflowWrap: "anywhere",
|
overflowWrap: "anywhere",
|
||||||
@@ -1146,7 +1060,6 @@ export function ChatPanel({
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [projectId, workspace, status]);
|
}, [projectId, workspace, status]);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
|
||||||
const [currentPhaseLabel, setCurrentPhaseLabel] = useState<string | null>(
|
const [currentPhaseLabel, setCurrentPhaseLabel] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -1977,11 +1890,6 @@ export function ChatPanel({
|
|||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div
|
<div
|
||||||
onScroll={(e) => {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
|
||||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
|
|
||||||
setShowScrollButton(distanceToBottom > 150);
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
@@ -2211,35 +2119,6 @@ export function ChatPanel({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Scroll to bottom button */}
|
|
||||||
{showScrollButton && (
|
|
||||||
<button
|
|
||||||
onClick={scrollToBottom}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "calc(100% + 12px)",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
background: "#ffffff",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
|
||||||
borderRadius: "50%",
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
cursor: "pointer",
|
|
||||||
zIndex: 50,
|
|
||||||
color: "#52525b",
|
|
||||||
transition: "all 0.2s ease",
|
|
||||||
}}
|
|
||||||
aria-label="Scroll to bottom"
|
|
||||||
>
|
|
||||||
<ArrowDown size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ProjectPreviewChatInputWrap unifiedShell={unifiedProjectShell}>
|
<ProjectPreviewChatInputWrap unifiedShell={unifiedProjectShell}>
|
||||||
{(selectToggle) => (
|
{(selectToggle) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user