diff --git a/apps/web/src/components/mcp-server-status.tsx b/apps/web/src/components/mcp-server-status.tsx index b348bdc..29ae3c1 100644 --- a/apps/web/src/components/mcp-server-status.tsx +++ b/apps/web/src/components/mcp-server-status.tsx @@ -150,9 +150,11 @@ const STATUS_LABEL: Record = { export function McpServerStatus({ servers, healthStatuses = {}, + onReconnected, }: { servers: McpServer[]; healthStatuses?: Record; + onReconnected?: () => void; }) { const { data: oauthStatuses } = useQuery( convexQuery(api.mcpOAuthTokens.listStatuses, {}), @@ -243,7 +245,7 @@ export function McpServerStatus({ key={server.url} server={server} status={status} - onReconnected={() => {}} + onReconnected={onReconnected} /> ))} @@ -261,7 +263,7 @@ function McpServerRow({ }: { server: McpServer; status: ServerStatus; - onReconnected: () => void; + onReconnected?: () => void; }) { const { getToken } = useAuth(); const [connecting, setConnecting] = useState(false); @@ -271,7 +273,7 @@ function McpServerRow({ startOAuthPopup(getToken, server.url, { onSuccess: () => { toast.success(`Reconnected to ${server.name}`); - onReconnected(); + onReconnected?.(); }, onError: (msg) => toast.error(msg), onDone: () => setConnecting(false), @@ -355,9 +357,11 @@ export function parseAuthRequiredError( export function OAuthReconnectPrompt({ serverUrl, errorMessage, + onReconnected, }: { serverUrl: string; errorMessage: string; + onReconnected?: () => void; }) { const { getToken } = useAuth(); const [connecting, setConnecting] = useState(false); @@ -369,11 +373,12 @@ export function OAuthReconnectPrompt({ onSuccess: () => { toast.success("Reconnected — you can retry the message"); setReconnected(true); + onReconnected?.(); }, onError: (msg) => toast.error(msg), onDone: () => setConnecting(false), }); - }, [getToken, serverUrl]); + }, [getToken, serverUrl, onReconnected]); if (reconnected) { return ( diff --git a/apps/web/src/components/slash-commands.tsx b/apps/web/src/components/slash-commands.tsx new file mode 100644 index 0000000..493708c --- /dev/null +++ b/apps/web/src/components/slash-commands.tsx @@ -0,0 +1,267 @@ +import { Wrench } from "lucide-react"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import toast from "react-hot-toast"; +import type { McpServerCommand } from "../lib/mcp"; +import { cn } from "../lib/utils"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type SlashCommand = McpServerCommand; + +// ─── Internal helpers ──────────────────────────────────────────────────────── + +function parseSlashCommand( + text: string, + commands: SlashCommand[], +): { toolName: string; message: string } | null { + if (!text.startsWith("/")) return null; + + const afterSlash = text.slice(1).trim(); + if (!afterSlash) return null; + + const sorted = [...commands].sort((a, b) => b.name.length - a.name.length); + + for (const cmd of sorted) { + if (afterSlash === cmd.name || afterSlash.startsWith(`${cmd.name} `)) { + return { + toolName: cmd.name, + message: afterSlash.slice(cmd.name.length).trim(), + }; + } + if (afterSlash === cmd.tool || afterSlash.startsWith(`${cmd.tool} `)) { + return { + toolName: cmd.name, + message: afterSlash.slice(cmd.tool.length).trim(), + }; + } + } + + return null; +} + +function filterCommands( + commands: SlashCommand[], + query: string, +): SlashCommand[] { + if (!query) return commands; + const q = query.toLowerCase(); + return commands.filter( + (cmd) => + cmd.tool.toLowerCase().includes(q) || + cmd.server.toLowerCase().includes(q) || + cmd.name.toLowerCase().includes(q) || + cmd.description.toLowerCase().includes(q), + ); +} + +function extractQuery(text: string): string { + if (!text.startsWith("/")) return ""; + const afterSlash = text.slice(1); + const spaceIdx = afterSlash.indexOf(" "); + return spaceIdx === -1 + ? afterSlash.toLowerCase() + : afterSlash.slice(0, spaceIdx).toLowerCase(); +} + +// ─── Main hook: useSlashCommandInput ───────────────────────────────────────── + +interface UseSlashCommandInputOptions { + storedCommands: SlashCommand[]; + text: string; + setText: (text: string) => void; + textareaRef: React.RefObject; +} + +export function useSlashCommandInput({ + storedCommands, + text, + setText, + textareaRef, +}: UseSlashCommandInputOptions) { + const commands = storedCommands; + const [menuOpen, setMenuOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + + // ── Menu open/close based on text ────────────────────────────────────────── + + useEffect(() => { + if (text.startsWith("/") && !text.includes("\n")) { + const parsed = parseSlashCommand(text, commands); + setMenuOpen(!parsed); + } else { + setMenuOpen(false); + } + }, [text, commands]); + + // ── Derived state ────────────────────────────────────────────────────────── + + const query = useMemo(() => extractQuery(text), [text]); + const filtered = useMemo( + () => filterCommands(commands, query), + [commands, query], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: query is intentionally used to reset selection when search changes + useEffect(() => { + setSelectedIndex(0); + }, [query]); + + // ── Select a command from the menu ───────────────────────────────────────── + + const selectCommand = useCallback( + (cmd: SlashCommand) => { + setText(`/${cmd.tool} `); + setMenuOpen(false); + textareaRef.current?.focus(); + }, + [setText, textareaRef], + ); + + // ── Keyboard handler ────────────────────────────────────────────────────── + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent): boolean => { + if (!menuOpen) return false; + + if (e.key === "Escape") { + e.preventDefault(); + setMenuOpen(false); + return true; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0)); + return true; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => + prev < filtered.length - 1 ? prev + 1 : prev, + ); + return true; + } + if (e.key === "Tab" || e.key === "Enter") { + e.preventDefault(); + const selected = filtered[selectedIndex]; + if (selected) selectCommand(selected); + return true; + } + return false; + }, + [menuOpen, filtered, selectedIndex, selectCommand], + ); + + // ── trySend ──────────────────────────────────────────────────────────────── + // Returns { forcedTool, message } if this is a slash command, or null if not. + // The caller should strip the command prefix and send the message through the + // normal chat stream with forced_tool set. + + const trySend = useCallback( + (content: string): { forcedTool: string; message: string } | null => { + const parsed = parseSlashCommand(content, commands); + if (!parsed) return null; + + if (!parsed.message) { + toast.error( + "Add a message after the command, e.g. /tool describe what you want", + ); + return { forcedTool: "", message: "" }; // signal "handled but don't send" + } + + setMenuOpen(false); + return { forcedTool: parsed.toolName, message: parsed.message }; + }, + [commands], + ); + + // ── Public API ───────────────────────────────────────────────────────────── + + return { + menuOpen, + commands, + filtered, + selectedIndex, + selectCommand, + handleKeyDown, + trySend, + }; +} + +// ─── Component: SlashCommandMenu ───────────────────────────────────────────── + +interface SlashCommandMenuProps { + isOpen: boolean; + commands: SlashCommand[]; + filtered: SlashCommand[]; + selectedIndex: number; + onSelect: (command: SlashCommand) => void; +} + +export function SlashCommandMenu({ + isOpen, + commands, + filtered, + selectedIndex, + onSelect, +}: SlashCommandMenuProps) { + const listRef = useRef(null); + + useEffect(() => { + if (!listRef.current) return; + const items = listRef.current.querySelectorAll("[data-command-item]"); + items[selectedIndex]?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + if (!isOpen) return null; + + return ( +
+
+ {filtered.length === 0 ? ( +
+ {commands.length === 0 + ? "No MCP tools available" + : "No commands match your search"} +
+ ) : ( + filtered.map((cmd, idx) => ( + + )) + )} +
+
+ ); +} diff --git a/apps/web/src/lib/mcp.ts b/apps/web/src/lib/mcp.ts index 45a130a..d1c46dc 100644 --- a/apps/web/src/lib/mcp.ts +++ b/apps/web/src/lib/mcp.ts @@ -2,11 +2,20 @@ import type { UserResource } from "@clerk/types"; export type McpAuthType = "none" | "bearer" | "oauth" | "tiger_junction"; +export interface McpServerCommand { + name: string; + server: string; + tool: string; + description: string; + parameters: Record; +} + export interface McpServerEntry { name: string; url: string; authType: McpAuthType; authToken?: string; + commandIds?: string[]; } export interface PresetMcpDefinition { @@ -198,6 +207,53 @@ export const PRESET_MCPS: PresetMcpDefinition[] = [ }, ]; +/** Build the API payload shape for MCP servers. */ +export function toMcpServerPayload(servers: McpServerEntry[]) { + return servers.map((s) => ({ + name: s.name, + url: s.url, + auth_type: s.authType, + ...(s.authToken ? { auth_token: s.authToken } : {}), + })); +} + +/** + * Fetch slash commands from the FastAPI backend. + * Returns the raw command list with $-prefixed keys stripped from parameters, + * or null if the fetch fails. + */ +export async function fetchCommandsFromApi( + apiUrl: string, + servers: McpServerEntry[], + token: string | null, +): Promise { + try { + const res = await fetch(`${apiUrl}/api/commands/list`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ mcp_servers: toMcpServerPayload(servers) }), + }); + if (!res.ok) return null; + const data = await res.json(); + return (data.commands ?? []).map((c: McpServerCommand) => ({ + name: c.name, + server: c.server, + tool: c.tool, + description: c.description, + parameters: c.parameters, + })); + } catch { + return null; + } +} + +/** Sanitize a name the same way the backend does (non-alphanumeric → underscore). */ +export const sanitizeServerName = (n: string) => + n.replace(/[^a-zA-Z0-9_-]/g, "_"); + /** Converts an array of selected preset IDs into their McpServerEntry objects. */ export function presetIdsToServerEntries(ids: string[]): McpServerEntry[] { return ids.flatMap((id) => { diff --git a/apps/web/src/lib/use-chat-stream.ts b/apps/web/src/lib/use-chat-stream.ts index 506e052..fa6b7af 100644 --- a/apps/web/src/lib/use-chat-stream.ts +++ b/apps/web/src/lib/use-chat-stream.ts @@ -98,6 +98,7 @@ export interface ChatStreamRequest { }; }; conversation_id: string; + forced_tool?: string; } export function useChatStream(callbacks: UseChatStreamCallbacks) { diff --git a/apps/web/src/routes/chat/index.tsx b/apps/web/src/routes/chat/index.tsx index 55dbf65..183250a 100644 --- a/apps/web/src/routes/chat/index.tsx +++ b/apps/web/src/routes/chat/index.tsx @@ -67,6 +67,10 @@ import { MessageAttachments } from "../../components/message-attachments"; import { SandboxPanel } from "../../components/sandbox/sandbox-panel"; import { SandboxResult } from "../../components/sandbox-result"; import { SkillViewerDialog } from "../../components/skill-viewer-dialog"; +import { + SlashCommandMenu, + useSlashCommandInput, +} from "../../components/slash-commands"; import { Avatar, AvatarFallback, @@ -109,7 +113,8 @@ import { UsageDialog } from "../../components/usage-dialog"; import { formatResetTime, UsageBadge } from "../../components/usage-display"; import { env } from "../../env"; import { useFileAttachments } from "../../hooks/use-file-attachments"; -import type { McpAuthType } from "../../lib/mcp"; +import type { McpAuthType, McpServerCommand } from "../../lib/mcp"; +import { fetchCommandsFromApi, sanitizeServerName } from "../../lib/mcp"; import { acceptString, allowedMimeTypes, @@ -219,6 +224,13 @@ function ChatPage() { Record >({}); + // Bump to force a slash-command refetch (after OAuth, harness edit, etc.) + const [commandRefreshKey, setCommandRefreshKey] = useState(0); + const refreshCommands = useCallback( + () => setCommandRefreshKey((k) => k + 1), + [], + ); + // Track conversations that just finished streaming (show green checkmark briefly) const [doneConvoIds, setDoneConvoIds] = useState>(new Set()); const prevStreamingRef = useRef>(new Set()); @@ -277,6 +289,9 @@ function ChatPage() { const updateHarness = useMutation({ mutationFn: useConvexMutation(api.harnesses.update), }); + const upsertCommandsMut = useMutation({ + mutationFn: useConvexMutation(api.commands.upsert), + }); // Save interrupted assistant message from frontend const saveInterruptedMsg = useMutation({ @@ -577,6 +592,18 @@ function ChatPage() { const activeHarness = harnesses?.find((h) => h._id === activeHarnessId); + // Collect all command IDs across the active harness's MCP servers + const allCommandIds = useMemo( + () => (activeHarness?.mcpServers ?? []).flatMap((s) => s.commandIds ?? []), + [activeHarness?.mcpServers], + ); + const { data: storedCommands } = useQuery( + convexQuery( + api.commands.getByIds, + allCommandIds.length > 0 ? { ids: allCommandIds } : "skip", + ), + ); + // Health-check MCP servers when harness changes // biome-ignore lint/correctness/useExhaustiveDependencies: only re-run when harness ID changes useEffect(() => { @@ -655,6 +682,70 @@ function ChatPage() { }; }, [activeHarness?._id, getToken]); + // Sync slash commands: fetch from MCP servers, upsert into commands table, + // and store the resulting IDs on the harness's mcpServers. + // Only runs on explicit triggers (OAuth reconnect, etc.) — NOT on harness + // switch or page load, since connecting to each MCP server is expensive. + // biome-ignore lint/correctness/useExhaustiveDependencies: only fires on commandRefreshKey + useEffect(() => { + if (commandRefreshKey === 0) return; // skip initial mount + if (!activeHarness || activeHarness.mcpServers.length === 0) return; + + let cancelled = false; + (async () => { + try { + const token = await getToken(); + const cmds = await fetchCommandsFromApi( + FASTAPI_URL, + activeHarness.mcpServers, + token, + ); + if (cancelled || !cmds || cmds.length === 0) return; + + // Upsert all commands into the commands table (stringify parameters) + const ids: string[] = await upsertCommandsMut.mutateAsync({ + commands: cmds.map((c) => ({ + name: c.name, + server: c.server, + tool: c.tool, + description: c.description, + parametersJson: JSON.stringify(c.parameters), + })), + }); + + if (cancelled) return; + + // Build a name→id map, then assign IDs to each mcpServer + const idByName = new Map( + cmds.map((c, i) => [c.name, ids[i] as Id<"commands">]), + ); + const enriched = activeHarness.mcpServers.map((s) => ({ + name: s.name, + url: s.url, + authType: s.authType, + ...(s.authToken ? { authToken: s.authToken } : {}), + commandIds: [...idByName.entries()] + .filter(([name]) => + name.startsWith(`${sanitizeServerName(s.name)}__`), + ) + .map(([, id]) => id), + })); + + if (!cancelled) { + updateHarness.mutate({ + id: activeHarness._id, + mcpServers: enriched, + }); + } + } catch { + // Non-blocking — commands are optional + } + })(); + return () => { + cancelled = true; + }; + }, [commandRefreshKey]); + const handleInterrupt = useCallback( (convoId: string) => { chatStream.cancel(convoId); @@ -986,6 +1077,7 @@ function ChatPage() { onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} isStreaming={isActiveConvoStreaming} mcpHealthStatuses={mcpHealthStatuses} + onRefreshCommands={refreshCommands} /> ({ + name: c?.name, + server: c?.server, + tool: c?.tool, + description: c?.description, + parameters: JSON.parse(c?.parametersJson), + }))} sessionModel={ userSettings?.modelSelectorMode === "harness" ? null @@ -1874,6 +1973,7 @@ function ChatHeader({ onToggleSidebar, isStreaming, mcpHealthStatuses, + onRefreshCommands, }: { harness?: { _id: Id<"harnesses">; @@ -1885,6 +1985,7 @@ function ChatHeader({ url: string; authType: McpAuthType; authToken?: string; + commandIds?: string[]; }>; skills: SkillEntry[]; sandboxEnabled?: boolean; @@ -1900,6 +2001,7 @@ function ChatHeader({ onToggleSidebar: () => void; isStreaming: boolean; mcpHealthStatuses?: Record; + onRefreshCommands: () => void; }) { return (
@@ -1959,6 +2061,7 @@ function ChatHeader({ )} @@ -3022,6 +3125,7 @@ function EmptyChat({ function ChatInput({ conversationId, activeHarness, + slashCommands, sessionModel, modelSelectorMode = "session", onSessionModelChange, @@ -3048,6 +3152,7 @@ function ChatInput({ url: string; authType: McpAuthType; authToken?: string; + commandIds?: string[]; }>; skills: SkillEntry[]; systemPrompt?: string; @@ -3060,6 +3165,7 @@ function ChatInput({ resourceTier: string; }; }; + slashCommands: McpServerCommand[]; onConvoCreated: (id: Id<"conversations">) => void; isStreaming: boolean; onStream: (body: { @@ -3080,6 +3186,7 @@ function ChatInput({ system_prompt?: string; }; conversation_id: string; + forced_tool?: string; }) => Promise; onInterrupt: (convoId: string) => void; onEnqueue: (content: string) => void; @@ -3137,6 +3244,14 @@ function ChatInput({ if (!supportsAnyAttachment) clearAttachments(); }, [supportsAnyAttachment, clearAttachments]); + // ── Slash commands ────────────────────────────────────────────── + const slash = useSlashCommandInput({ + storedCommands: slashCommands, + text, + setText, + textareaRef, + }); + // ── Voice recording ────────────────────────────────────────────── const [isRecording, setIsRecording] = useState(false); const mediaRecorderRef = useRef(null); @@ -3221,6 +3336,18 @@ function ChatInput({ const content = text.trim(); if (!content || !activeHarness || budgetExceeded) return; + // ── Slash command interception ──────────────────────────────── + // If it's a slash command, trySend returns the forced tool + cleaned message. + // We then send it through the normal chat flow with forced_tool set. + const slashResult = slash.trySend(content); + if (slashResult !== null) { + if (!slashResult.forcedTool) return; // validation error (e.g. no message), already toasted + } + + // Use the cleaned message for slash commands, or the raw content for normal messages + const messageContent = slashResult ? slashResult.message : content; + const forcedTool = slashResult?.forcedTool; + setText(""); setHistoryIndex(-1); setDraft(""); @@ -3277,7 +3404,7 @@ function ChatInput({ fileSize: a.fileSize, })); - // Save user message to Convex + // Save user message to Convex (original text including /command prefix) await sendMessage.mutateAsync({ conversationId: convoId, role: "user", @@ -3306,18 +3433,21 @@ function ChatInput({ } } + // For slash commands, send the cleaned message (without /command prefix) to the LLM + const llmContent = messageContent; + // Add the new user message (with any current attachments) if (readyAttachments.length > 0) { history.push({ role: "user", content: await buildMultimodalContent( - content, + llmContent, readyAttachments, resolveSignedUrls, ), }); } else { - history.push({ role: "user", content }); + history.push({ role: "user", content: llmContent }); } // Start streaming from FastAPI @@ -3325,10 +3455,14 @@ function ChatInput({ messages: history, harness: harnessConfig, conversation_id: convoId, + ...(forcedTool ? { forced_tool: forcedTool } : {}), }); }; const handleKeyDown = (e: KeyboardEvent) => { + // Slash command menu gets first shot at keyboard events + if (slash.handleKeyDown(e)) return; + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); @@ -3514,6 +3648,17 @@ function ChatInput({ )} + {/* Slash command autocomplete menu */} +
+ +
+
{/* Attach button — hidden for models that don't support media */} {supportsAnyAttachment && ( diff --git a/apps/web/src/routes/harnesses/$harnessId.tsx b/apps/web/src/routes/harnesses/$harnessId.tsx index 00674d5..3d797ae 100644 --- a/apps/web/src/routes/harnesses/$harnessId.tsx +++ b/apps/web/src/routes/harnesses/$harnessId.tsx @@ -66,7 +66,12 @@ import { Skeleton } from "../../components/ui/skeleton"; import { Textarea } from "../../components/ui/textarea"; import { env } from "../../env"; import type { McpServerEntry } from "../../lib/mcp"; -import { PRESET_MCPS } from "../../lib/mcp"; +import { + fetchCommandsFromApi, + PRESET_MCPS, + sanitizeServerName, + toMcpServerPayload, +} from "../../lib/mcp"; import { MODELS } from "../../lib/models"; import type { SkillEntry } from "../../lib/skills"; import { SYSTEM_PROMPT_MAX_LENGTH } from "../../lib/system-prompt"; @@ -93,6 +98,7 @@ function HarnessEditPage() { const { getToken } = useAuth(); const updateHarnessFn = useConvexMutation(api.harnesses.update); + const upsertCommandsFn = useConvexMutation(api.commands.upsert); const ensureSkillDetailsFn = useConvexAction(api.skills.ensureSkillDetails); const updateHarness = useMutation({ @@ -116,7 +122,7 @@ function HarnessEditPage() { ); } - // Regenerate suggested prompts when MCP servers changed + // Regenerate suggested prompts and commands when MCP servers changed if (savedMcpServers !== null && savedMcpServers.length > 0) { (async () => { try { @@ -130,12 +136,7 @@ function HarnessEditPage() { ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ - mcp_servers: savedMcpServers.map((s) => ({ - name: s.name, - url: s.url, - auth_type: s.authType, - ...(s.authToken ? { auth_token: s.authToken } : {}), - })), + mcp_servers: toMcpServerPayload(savedMcpServers), }), }, ); @@ -152,6 +153,49 @@ function HarnessEditPage() { // Non-blocking } })(); + + // Fire-and-forget: fetch commands, upsert, and link to harness + (async () => { + try { + const token = await getToken(); + const cmds = await fetchCommandsFromApi( + API_URL, + savedMcpServers, + token, + ); + if (!cmds || cmds.length === 0) return; + + const ids = await upsertCommandsFn({ + commands: cmds.map((c) => ({ + name: c.name, + server: c.server, + tool: c.tool, + description: c.description, + parametersJson: JSON.stringify(c.parameters), + })), + }); + + const idByName = new Map(cmds.map((c, i) => [c.name, ids[i]])); + const enriched = savedMcpServers.map((s) => ({ + name: s.name, + url: s.url, + authType: s.authType, + ...(s.authToken ? { authToken: s.authToken } : {}), + commandIds: [...idByName.entries()] + .filter(([name]) => + name.startsWith(`${sanitizeServerName(s.name)}__`), + ) + .map(([, cmdId]) => cmdId), + })); + + await updateHarnessFn({ + id: harnessId as Id<"harnesses">, + mcpServers: enriched, + }); + } catch { + // Non-blocking + } + })(); } }, }); diff --git a/apps/web/src/routes/onboarding.tsx b/apps/web/src/routes/onboarding.tsx index 0b6ff39..bc897a6 100644 --- a/apps/web/src/routes/onboarding.tsx +++ b/apps/web/src/routes/onboarding.tsx @@ -56,7 +56,13 @@ import { import { Textarea } from "../components/ui/textarea"; import { env } from "../env"; import type { McpServerEntry } from "../lib/mcp"; -import { PRESET_MCPS, presetIdsToServerEntries } from "../lib/mcp"; +import { + fetchCommandsFromApi, + PRESET_MCPS, + presetIdsToServerEntries, + sanitizeServerName, + toMcpServerPayload, +} from "../lib/mcp"; import { MODELS } from "../lib/models"; import type { SkillEntry } from "../lib/skills"; import { RECOMMENDED_SKILLS } from "../lib/skills"; @@ -136,6 +142,9 @@ function OnboardingPage() { const updateHarnessMut = useMutation({ mutationFn: useConvexMutation(api.harnesses.update), }); + // Direct Convex mutations for fire-and-forget command sync (survives unmount) + const upsertCommandsFn = useConvexMutation(api.commands.upsert); + const updateHarnessFn = useConvexMutation(api.harnesses.update); const ensureSkillDetailsFn = useConvexAction(api.skills.ensureSkillDetails); const { getToken } = useAuth(); @@ -166,12 +175,7 @@ function OnboardingPage() { ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ - mcp_servers: allMcpServers.map((s) => ({ - name: s.name, - url: s.url, - auth_type: s.authType, - ...(s.authToken ? { auth_token: s.authToken } : {}), - })), + mcp_servers: toMcpServerPayload(allMcpServers), }), }, ); @@ -188,6 +192,46 @@ function OnboardingPage() { // Non-blocking — prompts are optional } })(); + + // Fire-and-forget: fetch commands, upsert, and link to harness + (async () => { + try { + const token = await getToken(); + const cmds = await fetchCommandsFromApi( + API_URL, + allMcpServers, + token, + ); + if (!cmds || cmds.length === 0) return; + + const ids = await upsertCommandsFn({ + commands: cmds.map((c) => ({ + name: c.name, + server: c.server, + tool: c.tool, + description: c.description, + parametersJson: JSON.stringify(c.parameters), + })), + }); + + const idByName = new Map(cmds.map((c, i) => [c.name, ids[i]])); + const enriched = allMcpServers.map((s) => ({ + name: s.name, + url: s.url, + authType: s.authType, + ...(s.authToken ? { authToken: s.authToken } : {}), + commandIds: [...idByName.entries()] + .filter(([name]) => + name.startsWith(`${sanitizeServerName(s.name)}__`), + ) + .map(([, cmdId]) => cmdId), + })); + + await updateHarnessFn({ id, mcpServers: enriched }); + } catch { + // Non-blocking + } + })(); } }, }); @@ -206,12 +250,17 @@ function OnboardingPage() { if (safeIndex > 0) setStepIndex(safeIndex - 1); }; + // Strip commandIds from servers — commands are synced after creation in the chat page + const mcpServersForMutation = allMcpServers.map( + ({ commandIds: _, ...rest }) => rest, + ); + const handleCreate = () => { createHarness.mutate({ name: name.trim(), model, status: "started" as const, - mcpServers: allMcpServers, + mcpServers: mcpServersForMutation, skills: selectedSkills, systemPrompt: systemPrompt.trim() || undefined, sandboxEnabled: sandboxEnabled || undefined, @@ -224,7 +273,7 @@ function OnboardingPage() { name: name.trim() || "Untitled Harness", model: model || "gpt-4o", status: "draft" as const, - mcpServers: allMcpServers, + mcpServers: mcpServersForMutation, skills: selectedSkills, systemPrompt: systemPrompt.trim() || undefined, sandboxEnabled: sandboxEnabled || undefined, diff --git a/packages/convex-backend/convex/_generated/api.d.ts b/packages/convex-backend/convex/_generated/api.d.ts index c7e6eab..a7882d4 100644 --- a/packages/convex-backend/convex/_generated/api.d.ts +++ b/packages/convex-backend/convex/_generated/api.d.ts @@ -8,6 +8,7 @@ * @module */ +import type * as commands from "../commands.js"; import type * as conversations from "../conversations.js"; import type * as files from "../files.js"; import type * as harnesses from "../harnesses.js"; @@ -27,6 +28,7 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + commands: typeof commands; conversations: typeof conversations; files: typeof files; harnesses: typeof harnesses; diff --git a/packages/convex-backend/convex/commands.ts b/packages/convex-backend/convex/commands.ts new file mode 100644 index 0000000..7f0b3db --- /dev/null +++ b/packages/convex-backend/convex/commands.ts @@ -0,0 +1,62 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; + +/** + * Upsert commands: insert new ones, update existing ones (matched by name + userId). + * Returns an array of command IDs in the same order as the input. + */ +export const upsert = mutation({ + args: { + commands: v.array( + v.object({ + name: v.string(), + server: v.string(), + tool: v.string(), + description: v.string(), + parametersJson: v.string(), + }), + ), + }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("Unauthenticated"); + + // Batch-lookup existing commands by name + const existing = await Promise.all( + args.commands.map((cmd) => + ctx.db + .query("commands") + .withIndex("by_name", (q) => q.eq("name", cmd.name)) + .unique(), + ), + ); + + const ids = []; + for (let i = 0; i < args.commands.length; i++) { + const cmd = args.commands[i]; + const found = existing[i]; + if (found) { + await ctx.db.patch(found._id, { + server: cmd.server, + tool: cmd.tool, + description: cmd.description, + parametersJson: cmd.parametersJson, + }); + ids.push(found._id); + } else { + const id = await ctx.db.insert("commands", cmd); + ids.push(id); + } + } + return ids; + }, +}); + +/** Fetch commands by a list of IDs. */ +export const getByIds = query({ + args: { ids: v.array(v.id("commands")) }, + handler: async (ctx, args) => { + const results = await Promise.all(args.ids.map((id) => ctx.db.get(id))); + return results.filter(Boolean); + }, +}); diff --git a/packages/convex-backend/convex/harnesses.ts b/packages/convex-backend/convex/harnesses.ts index 1332cf5..18720ac 100644 --- a/packages/convex-backend/convex/harnesses.ts +++ b/packages/convex-backend/convex/harnesses.ts @@ -11,6 +11,13 @@ function assertSystemPromptLength(systemPrompt: string | undefined) { ); } } +const mcpServerValidator = v.object({ + name: v.string(), + url: v.string(), + authType: v.union(v.literal("none"), v.literal("bearer"), v.literal("oauth"), v.literal("tiger_junction")), + authToken: v.optional(v.string()), + commandIds: v.optional(v.array(v.id("commands"))), +}); export const list = query({ handler: async (ctx) => { @@ -43,14 +50,7 @@ export const create = mutation({ v.literal("stopped"), v.literal("draft"), ), - mcpServers: v.array( - v.object({ - name: v.string(), - url: v.string(), - authType: v.union(v.literal("none"), v.literal("bearer"), v.literal("oauth"), v.literal("tiger_junction")), - authToken: v.optional(v.string()), - }), - ), + mcpServers: v.array(mcpServerValidator), skills: v.array(v.object({ name: v.string(), description: v.string() })), systemPrompt: v.optional(v.string()), sandboxEnabled: v.optional(v.boolean()), @@ -94,16 +94,7 @@ export const update = mutation({ v.literal("draft"), ), ), - mcpServers: v.optional( - v.array( - v.object({ - name: v.string(), - url: v.string(), - authType: v.union(v.literal("none"), v.literal("bearer"), v.literal("oauth"), v.literal("tiger_junction")), - authToken: v.optional(v.string()), - }), - ), - ), + mcpServers: v.optional(v.array(mcpServerValidator)), skills: v.optional(v.array(v.object({ name: v.string(), description: v.string() }))), systemPrompt: v.optional(v.string()), suggestedPrompts: v.optional(v.array(v.string())), diff --git a/packages/convex-backend/convex/schema.ts b/packages/convex-backend/convex/schema.ts index 053140e..25468a2 100644 --- a/packages/convex-backend/convex/schema.ts +++ b/packages/convex-backend/convex/schema.ts @@ -16,6 +16,7 @@ export default defineSchema({ url: v.string(), authType: v.union(v.literal("none"), v.literal("bearer"), v.literal("oauth"), v.literal("tiger_junction")), authToken: v.optional(v.string()), + commandIds: v.optional(v.array(v.id("commands"))), }), ), skills: v.array(v.object({ name: v.string(), description: v.string() })), @@ -44,6 +45,14 @@ export default defineSchema({ ), }).index("by_user", ["userId"]), + commands: defineTable({ + name: v.string(), + server: v.string(), + tool: v.string(), + description: v.string(), + parametersJson: v.string(), + }).index("by_name", ["name"]), + sandboxes: defineTable({ userId: v.string(), harnessId: v.optional(v.id("harnesses")), diff --git a/packages/fastapi/app/main.py b/packages/fastapi/app/main.py index 3af1aca..cf916cd 100644 --- a/packages/fastapi/app/main.py +++ b/packages/fastapi/app/main.py @@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.routes import chat, health, mcp_health, mcp_oauth, sandbox, terminal +from app.routes import chat, commands, health, mcp_health, mcp_oauth, sandbox, terminal logging.basicConfig( level=logging.INFO, @@ -65,5 +65,6 @@ async def lifespan(app: FastAPI): app.include_router(chat.router, prefix="/api/chat", tags=["chat"]) app.include_router(mcp_oauth.router, prefix="/api/mcp/oauth", tags=["mcp-oauth"]) app.include_router(mcp_health.router, prefix="/api/mcp/health", tags=["mcp-health"]) +app.include_router(commands.router, prefix="/api/commands", tags=["commands"]) app.include_router(sandbox.router, prefix="/api/sandbox", tags=["sandbox"]) app.include_router(terminal.router, prefix="/api/sandbox", tags=["terminal"]) diff --git a/packages/fastapi/app/models.py b/packages/fastapi/app/models.py index 208ab4b..d711e96 100644 --- a/packages/fastapi/app/models.py +++ b/packages/fastapi/app/models.py @@ -46,6 +46,7 @@ class ChatRequest(BaseModel): messages: list[MessagePayload] harness: HarnessConfig conversation_id: str + forced_tool: str | None = None class SandboxCreateRequest(BaseModel): @@ -91,3 +92,9 @@ class GitAddRequest(BaseModel): class GitCommitRequest(BaseModel): path: str = "/home/daytona" message: str + + +class CommandListRequest(BaseModel): + mcp_servers: list[McpServer] = [] + + diff --git a/packages/fastapi/app/routes/chat.py b/packages/fastapi/app/routes/chat.py index 708fc23..4098d78 100644 --- a/packages/fastapi/app/routes/chat.py +++ b/packages/fastapi/app/routes/chat.py @@ -568,12 +568,21 @@ async def event_generator(): client_disconnected = False + # Force a specific tool on the first iteration when forced_tool is set + tool_choice: dict | str | None = None + if body.forced_tool and iteration == 0 and tools: + tool_choice = { + "type": "function", + "function": {"name": body.forced_tool}, + } + try: async for chunk in stream_chat( http_client, messages, body.harness.model, tools, + tool_choice=tool_choice, ): if not client_disconnected and await request.is_disconnected(): logger.info( diff --git a/packages/fastapi/app/routes/commands.py b/packages/fastapi/app/routes/commands.py new file mode 100644 index 0000000..15c0894 --- /dev/null +++ b/packages/fastapi/app/routes/commands.py @@ -0,0 +1,47 @@ +import logging + +import httpx +from fastapi import APIRouter, Depends + +from app.dependencies import get_current_user, get_http_client +from app.models import CommandListRequest +from app.services.mcp_client import UserContext, list_tools, resolve_princeton_netid + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.post("/list") +async def command_list( + body: CommandListRequest, + http_client: httpx.AsyncClient = Depends(get_http_client), + user: dict = Depends(get_current_user), +): + """Return available MCP tools as slash commands.""" + user_id = user.get("sub") + netid = await resolve_princeton_netid(http_client, user) + user_ctx = UserContext(user_id=user_id, princeton_netid=netid) + + tools, failures = await list_tools(http_client, body.mcp_servers, user_ctx=user_ctx) + + commands = [] + for tool in tools: + fn = tool["function"] + parts = fn["name"].split("__", 1) + server = parts[0] if len(parts) == 2 else "" + tool_name = parts[1] if len(parts) == 2 else fn["name"] + commands.append({ + "name": fn["name"], + "server": server, + "tool": tool_name, + "description": fn.get("description", ""), + "parameters": fn.get("parameters", {}), + }) + + return { + "commands": commands, + "failures": [ + {"server_name": f.server_name, "server_url": f.server_url, "reason": f.reason} + for f in failures + ], + } diff --git a/packages/fastapi/app/services/openrouter.py b/packages/fastapi/app/services/openrouter.py index 45014e8..f12aaf0 100644 --- a/packages/fastapi/app/services/openrouter.py +++ b/packages/fastapi/app/services/openrouter.py @@ -16,6 +16,7 @@ async def stream_chat( messages: list[dict], model: str, tools: list[dict] | None = None, + tool_choice: dict | str | None = None, ) -> AsyncGenerator[dict, None]: """Stream chat completion from OpenRouter. Yields parsed SSE chunks. @@ -24,6 +25,7 @@ async def stream_chat( messages: OpenAI-format message list. model: Short model name (e.g. "claude-sonnet-4") or full OpenRouter ID. tools: Optional OpenAI-format tool definitions. + tool_choice: Optional tool_choice override (e.g. to force a specific tool). """ resolved_model = MODEL_MAP.get(model, model) logger.debug( @@ -45,7 +47,7 @@ async def stream_chat( payload["reasoning"] = {"effort": "high"} if tools: payload["tools"] = tools - payload["tool_choice"] = "auto" + payload["tool_choice"] = tool_choice or "auto" headers = { "Authorization": f"Bearer {settings.openrouter_api_key}",