Skip to content

Commit 1f50efb

Browse files
committed
feat(agents): show last used timestamp on agent card and settings
1 parent 9bd12b9 commit 1f50efb

14 files changed

Lines changed: 190 additions & 7 deletions

File tree

apps/mesh/src/api/routes/decopilot/run-reactor.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ function makeDeps(): RunReactorDeps {
2828
addInflightAsyncJob: mock(() => Promise.resolve()),
2929
findInflightAsyncJob: mock(() => Promise.resolve(null)),
3030
removeInflightAsyncJob: mock(() => Promise.resolve()),
31+
findLastUsedByVirtualMcpIds: mock(() => Promise.resolve(new Map())),
3132
},
3233
streamBuffer: { purge: mock(() => {}) } as unknown as StreamBuffer,
3334
sseHub: { emit: mock(() => {}) },

apps/mesh/src/api/routes/decopilot/run-registry.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ function makeNoopDeps(): RunReactorDeps {
2727
addInflightAsyncJob: mock(() => Promise.resolve()),
2828
findInflightAsyncJob: mock(() => Promise.resolve(null)),
2929
removeInflightAsyncJob: mock(() => Promise.resolve()),
30+
findLastUsedByVirtualMcpIds: mock(() => Promise.resolve(new Map())),
3031
},
3132
streamBuffer: { purge: mock(() => {}) } as unknown as StreamBuffer,
3233
sseHub: { emit: mock(() => {}) },

apps/mesh/src/storage/ports.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ export interface ThreadStoragePort {
120120
query: string,
121121
): Promise<void>;
122122

123+
/**
124+
* For each given virtual MCP id, return the timestamp and creator of the most recent thread.
125+
* Used by the dedicated last-used endpoint; not on the agent fetch hot path.
126+
*/
127+
findLastUsedByVirtualMcpIds(
128+
organizationId: string,
129+
virtualMcpIds: string[],
130+
): Promise<Map<string, { last_used_at: string; last_used_by: string }>>;
131+
123132
// Message operations - upserts by id (updates existing rows)
124133
saveMessages(data: ThreadMessage[], organizationId: string): Promise<void>;
125134
listMessages(

apps/mesh/src/storage/threads.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,15 @@ export class OrgScopedThreadStorage {
136136
return this.inner.listByTriggerIds(this.requireOrg(), triggerIds, options);
137137
}
138138

139+
findLastUsedByVirtualMcpIds(
140+
virtualMcpIds: string[],
141+
): Promise<Map<string, { last_used_at: string; last_used_by: string }>> {
142+
return this.inner.findLastUsedByVirtualMcpIds(
143+
this.requireOrg(),
144+
virtualMcpIds,
145+
);
146+
}
147+
139148
addInflightAsyncJob(taskId: string, entry: InflightAsyncJob): Promise<void> {
140149
return this.inner.addInflightAsyncJob(taskId, this.requireOrg(), entry);
141150
}
@@ -508,6 +517,35 @@ export class SqlThreadStorage implements ThreadStoragePort {
508517
};
509518
}
510519

520+
async findLastUsedByVirtualMcpIds(
521+
organizationId: string,
522+
virtualMcpIds: string[],
523+
): Promise<Map<string, { last_used_at: string; last_used_by: string }>> {
524+
const result = new Map<
525+
string,
526+
{ last_used_at: string; last_used_by: string }
527+
>();
528+
if (virtualMcpIds.length === 0) return result;
529+
530+
const rows = await this.db
531+
.selectFrom("threads")
532+
.distinctOn("virtual_mcp_id")
533+
.select(["virtual_mcp_id", "created_by", "created_at"])
534+
.where("organization_id", "=", organizationId)
535+
.where("virtual_mcp_id", "in", virtualMcpIds)
536+
.orderBy("virtual_mcp_id")
537+
.orderBy("created_at", "desc")
538+
.execute();
539+
540+
for (const row of rows) {
541+
result.set(row.virtual_mcp_id, {
542+
last_used_at: toIsoString(row.created_at),
543+
last_used_by: row.created_by,
544+
});
545+
}
546+
return result;
547+
}
548+
511549
/**
512550
* Upserts thread messages by id.
513551
* Inserts new messages; updates existing rows (by id) with parts, metadata, role, updated_at.

apps/mesh/src/tools/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ const CORE_TOOLS = [
132132
VirtualMCPTools.VIRTUAL_MCP_PLUGIN_CONFIG_GET,
133133
VirtualMCPTools.VIRTUAL_MCP_PLUGIN_CONFIG_UPDATE,
134134
VirtualMCPTools.VIRTUAL_MCP_PINNED_VIEWS_UPDATE,
135+
VirtualMCPTools.VIRTUAL_MCP_LAST_USED_LIST,
135136

136137
// Ai providers tools
137138
AiProvidersTools.AI_PROVIDERS_LIST,

apps/mesh/src/tools/registry-metadata.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ const ALL_TOOL_NAMES = [
123123
"VIRTUAL_MCP_PLUGIN_CONFIG_GET",
124124
"VIRTUAL_MCP_PLUGIN_CONFIG_UPDATE",
125125
"VIRTUAL_MCP_PINNED_VIEWS_UPDATE",
126+
"VIRTUAL_MCP_LAST_USED_LIST",
126127

127128
// Ai providers tools
128129
"AI_PROVIDERS_LIST",
@@ -608,6 +609,11 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [
608609
description: "Update virtual MCP pinned sidebar views",
609610
category: "Virtual MCPs",
610611
},
612+
{
613+
name: "VIRTUAL_MCP_LAST_USED_LIST",
614+
description: "Get last-used info for one or more virtual MCPs",
615+
category: "Virtual MCPs",
616+
},
611617
{
612618
name: "AI_PROVIDERS_LIST",
613619
description: "List available AI providers",
@@ -924,6 +930,7 @@ const PERMISSION_CAPABILITIES: PermissionCapability[] = [
924930
"COLLECTION_VIRTUAL_MCP_LIST",
925931
"COLLECTION_VIRTUAL_MCP_GET",
926932
"VIRTUAL_MCP_PLUGIN_CONFIG_GET",
933+
"VIRTUAL_MCP_LAST_USED_LIST",
927934
// View automations
928935
"AUTOMATION_GET",
929936
"AUTOMATION_LIST",

apps/mesh/src/tools/virtual/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export { COLLECTION_VIRTUAL_MCP_DELETE } from "./delete";
1515
export { VIRTUAL_MCP_PLUGIN_CONFIG_GET } from "./plugin-config-get";
1616
export { VIRTUAL_MCP_PLUGIN_CONFIG_UPDATE } from "./plugin-config-update";
1717
export { VIRTUAL_MCP_PINNED_VIEWS_UPDATE } from "./pinned-views-update";
18+
export { VIRTUAL_MCP_LAST_USED_LIST } from "./last-used-list";
1819

1920
// Re-export schema types (only types, not runtime schemas)
2021
export type {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* VIRTUAL_MCP_LAST_USED_LIST Tool
3+
*
4+
* Returns the most recent thread timestamp + creator per virtual MCP id.
5+
* Kept on a dedicated endpoint so the agent fetch hot path doesn't pay for
6+
* this query on every request.
7+
*/
8+
9+
import { z } from "zod";
10+
import { defineTool } from "../../core/define-tool";
11+
import { requireAuth, requireOrganization } from "../../core/mesh-context";
12+
13+
const InputSchema = z.object({
14+
ids: z.array(z.string()).describe("Virtual MCP ids to look up"),
15+
});
16+
17+
const OutputSchema = z.object({
18+
items: z.array(
19+
z.object({
20+
id: z.string(),
21+
last_used_at: z.string().optional(),
22+
last_used_by: z.string().optional(),
23+
}),
24+
),
25+
});
26+
27+
export const VIRTUAL_MCP_LAST_USED_LIST = defineTool({
28+
name: "VIRTUAL_MCP_LAST_USED_LIST",
29+
description:
30+
"Get last-used info (timestamp + user) for one or more virtual MCPs, derived from the most recent thread per agent.",
31+
annotations: {
32+
title: "List Virtual MCP Last Used",
33+
readOnlyHint: true,
34+
destructiveHint: false,
35+
idempotentHint: true,
36+
openWorldHint: false,
37+
},
38+
inputSchema: InputSchema,
39+
outputSchema: OutputSchema,
40+
41+
handler: async (input, ctx) => {
42+
requireAuth(ctx);
43+
requireOrganization(ctx);
44+
await ctx.access.check();
45+
46+
const map = await ctx.storage.threads.findLastUsedByVirtualMcpIds(
47+
input.ids,
48+
);
49+
50+
return {
51+
items: input.ids.map((id) => ({
52+
id,
53+
last_used_at: map.get(id)?.last_used_at,
54+
last_used_by: map.get(id)?.last_used_by,
55+
})),
56+
};
57+
},
58+
});

apps/mesh/src/web/components/project-card.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@ import {
1414

1515
interface ProjectCardProps {
1616
project: VirtualMCPEntity;
17+
lastUsedAt?: string;
1718
onDeleteClick?: (e: React.MouseEvent) => void;
1819
}
1920

20-
export function ProjectCard({ project, onDeleteClick }: ProjectCardProps) {
21+
export function ProjectCard({
22+
project,
23+
lastUsedAt,
24+
onDeleteClick,
25+
}: ProjectCardProps) {
2126
const navigateToAgent = useNavigateToAgent();
2227

2328
return (
@@ -91,9 +96,9 @@ export function ProjectCard({ project, onDeleteClick }: ProjectCardProps) {
9196
<div className="border-t border-border mt-auto">
9297
<div className="h-10 flex items-center px-4.5">
9398
<p className="text-xs text-muted-foreground">
94-
{formatDistanceToNow(new Date(project.updated_at), {
95-
addSuffix: true,
96-
})}
99+
{lastUsedAt
100+
? `Last used ${formatDistanceToNow(new Date(lastUsedAt), { addSuffix: true })}`
101+
: `Updated ${formatDistanceToNow(new Date(project.updated_at), { addSuffix: true })}`}
97102
</p>
98103
</div>
99104
</div>

apps/mesh/src/web/routes/agents-list.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
useProjectContext,
77
useVirtualMCPActions,
88
useVirtualMCPs,
9+
useVirtualMCPsLastUsed,
910
} from "@decocms/mesh-sdk";
1011
import { Page } from "@/web/components/page";
1112
import { ProjectCard } from "@/web/components/project-card";
@@ -68,6 +69,10 @@ export default function AgentsListPage() {
6869
s.description?.toLowerCase().includes(lowerSearch)),
6970
);
7071

72+
const { data: lastUsedMap } = useVirtualMCPsLastUsed(
73+
filteredAgents.map((a) => a.id),
74+
);
75+
7176
// Check if studio pack is already installed
7277
const studioPackInstalled = agents.some((a) => isStudioPackAgent(a.id));
7378

@@ -245,6 +250,7 @@ export default function AgentsListPage() {
245250
<ProjectCard
246251
key={agent.id}
247252
project={agent}
253+
lastUsedAt={lastUsedMap?.get(agent.id)?.last_used_at}
248254
onDeleteClick={() =>
249255
setDeleteTarget({
250256
id: agent.id,

0 commit comments

Comments
 (0)