diff --git a/apps/mesh/src/storage/ports.ts b/apps/mesh/src/storage/ports.ts index eaa1065cac..bdb43986c9 100644 --- a/apps/mesh/src/storage/ports.ts +++ b/apps/mesh/src/storage/ports.ts @@ -93,6 +93,15 @@ export interface ThreadStoragePort { /** Release ownership for all runs owned by this pod (graceful shutdown). */ orphanRunsByPod(podId: string): Promise; + /** + * For each given virtual MCP id, return the timestamp and creator of the most recent thread. + * Used by the dedicated last-used endpoint; not on the agent fetch hot path. + */ + findLastUsedByVirtualMcpIds( + organizationId: string, + virtualMcpIds: string[], + ): Promise>; + // Message operations - upserts by id (updates existing rows) saveMessages(data: ThreadMessage[], organizationId: string): Promise; listMessages( diff --git a/apps/mesh/src/storage/threads.ts b/apps/mesh/src/storage/threads.ts index 1ca9feb6c2..a5f160b863 100644 --- a/apps/mesh/src/storage/threads.ts +++ b/apps/mesh/src/storage/threads.ts @@ -115,6 +115,15 @@ export class OrgScopedThreadStorage { return this.inner.listByTriggerIds(this.requireOrg(), triggerIds, options); } + findLastUsedByVirtualMcpIds( + virtualMcpIds: string[], + ): Promise> { + return this.inner.findLastUsedByVirtualMcpIds( + this.requireOrg(), + virtualMcpIds, + ); + } + saveMessages(data: ThreadMessage[]): Promise { return this.inner.saveMessages(data, this.requireOrg()); } @@ -468,6 +477,35 @@ export class SqlThreadStorage implements ThreadStoragePort { }; } + async findLastUsedByVirtualMcpIds( + organizationId: string, + virtualMcpIds: string[], + ): Promise> { + const result = new Map< + string, + { last_used_at: string; last_used_by: string } + >(); + if (virtualMcpIds.length === 0) return result; + + const rows = await this.db + .selectFrom("threads") + .distinctOn("virtual_mcp_id") + .select(["virtual_mcp_id", "created_by", "created_at"]) + .where("organization_id", "=", organizationId) + .where("virtual_mcp_id", "in", virtualMcpIds) + .orderBy("virtual_mcp_id") + .orderBy("created_at", "desc") + .execute(); + + for (const row of rows) { + result.set(row.virtual_mcp_id, { + last_used_at: toIsoString(row.created_at), + last_used_by: row.created_by, + }); + } + return result; + } + /** * Upserts thread messages by id. * Inserts new messages; updates existing rows (by id) with parts, metadata, role, updated_at. diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index aa81799b40..da63fccf1d 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -137,6 +137,7 @@ const CORE_TOOLS = [ VirtualMCPTools.VIRTUAL_MCP_PLUGIN_CONFIG_GET, VirtualMCPTools.VIRTUAL_MCP_PLUGIN_CONFIG_UPDATE, VirtualMCPTools.VIRTUAL_MCP_PINNED_VIEWS_UPDATE, + VirtualMCPTools.VIRTUAL_MCP_LAST_USED_LIST, // Ai providers tools AiProvidersTools.AI_PROVIDERS_LIST, diff --git a/apps/mesh/src/tools/registry-metadata.ts b/apps/mesh/src/tools/registry-metadata.ts index 8c5978416c..b254ae6dbc 100644 --- a/apps/mesh/src/tools/registry-metadata.ts +++ b/apps/mesh/src/tools/registry-metadata.ts @@ -128,6 +128,7 @@ const ALL_TOOL_NAMES = [ "VIRTUAL_MCP_PLUGIN_CONFIG_GET", "VIRTUAL_MCP_PLUGIN_CONFIG_UPDATE", "VIRTUAL_MCP_PINNED_VIEWS_UPDATE", + "VIRTUAL_MCP_LAST_USED_LIST", // Ai providers tools "AI_PROVIDERS_LIST", @@ -635,6 +636,11 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "Update virtual MCP pinned sidebar views", category: "Virtual MCPs", }, + { + name: "VIRTUAL_MCP_LAST_USED_LIST", + description: "Get last-used info for one or more virtual MCPs", + category: "Virtual MCPs", + }, { name: "AI_PROVIDERS_LIST", description: "List available AI providers", @@ -1005,6 +1011,7 @@ const PERMISSION_CAPABILITIES: PermissionCapability[] = [ "COLLECTION_VIRTUAL_MCP_LIST", "COLLECTION_VIRTUAL_MCP_GET", "VIRTUAL_MCP_PLUGIN_CONFIG_GET", + "VIRTUAL_MCP_LAST_USED_LIST", // View automations "AUTOMATION_GET", "AUTOMATION_LIST", diff --git a/apps/mesh/src/tools/virtual/index.ts b/apps/mesh/src/tools/virtual/index.ts index 3308510ec3..75b9b16073 100644 --- a/apps/mesh/src/tools/virtual/index.ts +++ b/apps/mesh/src/tools/virtual/index.ts @@ -15,6 +15,7 @@ export { COLLECTION_VIRTUAL_MCP_DELETE } from "./delete"; export { VIRTUAL_MCP_PLUGIN_CONFIG_GET } from "./plugin-config-get"; export { VIRTUAL_MCP_PLUGIN_CONFIG_UPDATE } from "./plugin-config-update"; export { VIRTUAL_MCP_PINNED_VIEWS_UPDATE } from "./pinned-views-update"; +export { VIRTUAL_MCP_LAST_USED_LIST } from "./last-used-list"; // Re-export schema types (only types, not runtime schemas) export type { diff --git a/apps/mesh/src/tools/virtual/last-used-list.ts b/apps/mesh/src/tools/virtual/last-used-list.ts new file mode 100644 index 0000000000..9a9bfa2e63 --- /dev/null +++ b/apps/mesh/src/tools/virtual/last-used-list.ts @@ -0,0 +1,58 @@ +/** + * VIRTUAL_MCP_LAST_USED_LIST Tool + * + * Returns the most recent thread timestamp + creator per virtual MCP id. + * Kept on a dedicated endpoint so the agent fetch hot path doesn't pay for + * this query on every request. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { requireAuth, requireOrganization } from "../../core/mesh-context"; + +const InputSchema = z.object({ + ids: z.array(z.string()).describe("Virtual MCP ids to look up"), +}); + +const OutputSchema = z.object({ + items: z.array( + z.object({ + id: z.string(), + last_used_at: z.string().optional(), + last_used_by: z.string().optional(), + }), + ), +}); + +export const VIRTUAL_MCP_LAST_USED_LIST = defineTool({ + name: "VIRTUAL_MCP_LAST_USED_LIST", + description: + "Get last-used info (timestamp + user) for one or more virtual MCPs, derived from the most recent thread per agent.", + annotations: { + title: "List Virtual MCP Last Used", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: InputSchema, + outputSchema: OutputSchema, + + handler: async (input, ctx) => { + requireAuth(ctx); + requireOrganization(ctx); + await ctx.access.check(); + + const map = await ctx.storage.threads.findLastUsedByVirtualMcpIds( + input.ids, + ); + + return { + items: input.ids.map((id) => ({ + id, + last_used_at: map.get(id)?.last_used_at, + last_used_by: map.get(id)?.last_used_by, + })), + }; + }, +}); diff --git a/apps/mesh/src/web/components/project-card.tsx b/apps/mesh/src/web/components/project-card.tsx index 598fa7b542..f3c7ef6c37 100644 --- a/apps/mesh/src/web/components/project-card.tsx +++ b/apps/mesh/src/web/components/project-card.tsx @@ -14,10 +14,15 @@ import { interface ProjectCardProps { project: VirtualMCPEntity; + lastUsedAt?: string; onDeleteClick?: (e: React.MouseEvent) => void; } -export function ProjectCard({ project, onDeleteClick }: ProjectCardProps) { +export function ProjectCard({ + project, + lastUsedAt, + onDeleteClick, +}: ProjectCardProps) { const navigateToAgent = useNavigateToAgent(); return ( @@ -91,9 +96,9 @@ export function ProjectCard({ project, onDeleteClick }: ProjectCardProps) {

- {formatDistanceToNow(new Date(project.updated_at), { - addSuffix: true, - })} + {lastUsedAt + ? `Last used ${formatDistanceToNow(new Date(lastUsedAt), { addSuffix: true })}` + : `Updated ${formatDistanceToNow(new Date(project.updated_at), { addSuffix: true })}`}

diff --git a/apps/mesh/src/web/routes/agents-list.tsx b/apps/mesh/src/web/routes/agents-list.tsx index f48e6ff3c5..034dd09306 100644 --- a/apps/mesh/src/web/routes/agents-list.tsx +++ b/apps/mesh/src/web/routes/agents-list.tsx @@ -4,6 +4,7 @@ import { useProjectContext, useVirtualMCPActions, useVirtualMCPs, + useVirtualMCPsLastUsed, } from "@decocms/mesh-sdk"; import { Page } from "@/web/components/page"; import { ProjectCard } from "@/web/components/project-card"; @@ -63,6 +64,10 @@ export default function AgentsListPage() { s.description?.toLowerCase().includes(lowerSearch)), ); + const { data: lastUsedMap } = useVirtualMCPsLastUsed( + filteredAgents.map((a) => a.id), + ); + const confirmDelete = async () => { if (!deleteTarget) return; const { id, title } = deleteTarget; @@ -223,6 +228,7 @@ export default function AgentsListPage() { setDeleteTarget({ id: agent.id, diff --git a/apps/mesh/src/web/views/virtual-mcp/index.tsx b/apps/mesh/src/web/views/virtual-mcp/index.tsx index 9542fd7a45..6861e35274 100644 --- a/apps/mesh/src/web/views/virtual-mcp/index.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/index.tsx @@ -1,3 +1,4 @@ +import { formatDistanceToNow } from "date-fns"; import { generatePrefixedId } from "@/shared/utils/generate-id"; import type { VirtualMCPEntity } from "@/tools/virtual/schema"; import { getUIResourceUri } from "@/mcp-apps/types.ts"; @@ -64,6 +65,7 @@ import { useProjectContext, useVirtualMCP, useVirtualMCPActions, + useVirtualMCPsLastUsed, } from "@decocms/mesh-sdk"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -1146,6 +1148,8 @@ function VirtualMcpDetailViewWithData({ }) { const { org } = useProjectContext(); const actions = useVirtualMCPActions(); + const { data: lastUsedMap } = useVirtualMCPsLastUsed([virtualMcp.id]); + const lastUsedAt = lastUsedMap?.get(virtualMcp.id)?.last_used_at; const connectionActions = useConnectionActions(); const queryClient = useQueryClient(); const client = useMCPClient({ @@ -1724,20 +1728,27 @@ Define step-by-step how the agent should handle requests. {/* Creator metadata */} -
+
- · - + · + + Created{" "} {new Date(virtualMcp.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", })} + · + + {lastUsedAt + ? `Last used ${formatDistanceToNow(new Date(lastUsedAt), { addSuffix: true })}` + : "Never used"} +
{/* Connections section */} diff --git a/packages/mesh-sdk/src/hooks/index.ts b/packages/mesh-sdk/src/hooks/index.ts index ef88375a97..7d5277b1b5 100644 --- a/packages/mesh-sdk/src/hooks/index.ts +++ b/packages/mesh-sdk/src/hooks/index.ts @@ -75,6 +75,8 @@ export { useVirtualMCPs, useVirtualMCP, useVirtualMCPActions, + useVirtualMCPsLastUsed, type VirtualMCPFilter, type UseVirtualMCPsOptions, + type VirtualMCPLastUsed, } from "./use-virtual-mcp"; diff --git a/packages/mesh-sdk/src/hooks/use-virtual-mcp.ts b/packages/mesh-sdk/src/hooks/use-virtual-mcp.ts index 1cd82eb7db..50de57c9c4 100644 --- a/packages/mesh-sdk/src/hooks/use-virtual-mcp.ts +++ b/packages/mesh-sdk/src/hooks/use-virtual-mcp.ts @@ -5,6 +5,8 @@ * These hooks offer a reactive interface for accessing and manipulating virtual MCPs. */ +import { useQuery } from "@tanstack/react-query"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { VirtualMCPEntity } from "../types/virtual-mcp"; import { useProjectContext } from "../context"; import { @@ -16,6 +18,13 @@ import { } from "./use-collections"; import { useMCPClient } from "./use-mcp-client"; import { SELF_MCP_ALIAS_ID } from "../lib/constants"; +import { KEYS } from "../lib/query-keys"; + +export interface VirtualMCPLastUsed { + id: string; + last_used_at?: string; + last_used_by?: string; +} /** * Filter definition for virtual MCPs (matches @deco/ui Filter shape) @@ -77,6 +86,39 @@ export function useVirtualMCP( return dbVirtualMCP; } +/** + * Hook to fetch last-used info (most recent thread timestamp + user) for a set + * of virtual MCPs. Backed by VIRTUAL_MCP_LAST_USED_LIST so the data isn't + * loaded on the agent fetch hot path. + */ +export function useVirtualMCPsLastUsed(ids: string[]) { + const { org } = useProjectContext(); + const client = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + orgSlug: org.slug, + }); + const sortedIds = [...ids].sort(); + + return useQuery>({ + queryKey: KEYS.virtualMcpLastUsed(org.id, sortedIds), + enabled: sortedIds.length > 0, + staleTime: 30_000, + queryFn: async () => { + const result = (await client.callTool({ + name: "VIRTUAL_MCP_LAST_USED_LIST", + arguments: { ids: sortedIds }, + })) as CallToolResult; + const payload = (result.structuredContent ?? { items: [] }) as { + items: VirtualMCPLastUsed[]; + }; + const map = new Map(); + for (const item of payload.items) map.set(item.id, item); + return map; + }, + }); +} + /** * Hook to get virtual MCP mutation actions (create, update, delete) * diff --git a/packages/mesh-sdk/src/index.ts b/packages/mesh-sdk/src/index.ts index 75426cf467..2f3c9f0679 100644 --- a/packages/mesh-sdk/src/index.ts +++ b/packages/mesh-sdk/src/index.ts @@ -73,8 +73,10 @@ export { useVirtualMCPs, useVirtualMCP, useVirtualMCPActions, + useVirtualMCPsLastUsed, type VirtualMCPFilter, type UseVirtualMCPsOptions, + type VirtualMCPLastUsed, } from "./hooks"; // Types diff --git a/packages/mesh-sdk/src/lib/query-keys.ts b/packages/mesh-sdk/src/lib/query-keys.ts index 5fae2f73e6..8084ce9ba5 100644 --- a/packages/mesh-sdk/src/lib/query-keys.ts +++ b/packages/mesh-sdk/src/lib/query-keys.ts @@ -81,6 +81,10 @@ export const KEYS = { // Models list (scoped by organization) modelsList: (orgId: string) => ["models-list", orgId] as const, + // Virtual MCP last-used info (most recent thread per agent) + virtualMcpLastUsed: (orgId: string, ids: string[]) => + ["virtual-mcp", "last-used", orgId, ids] as const, + // Collections (scoped by connection) connectionCollections: (connectionId: string) => [connectionId, "collections", "discovery"] as const,