diff --git a/packages/api/src/routes/mcp/tools.ts b/packages/api/src/routes/mcp/tools.ts index e2a99dae..6aca10e2 100644 --- a/packages/api/src/routes/mcp/tools.ts +++ b/packages/api/src/routes/mcp/tools.ts @@ -252,13 +252,18 @@ export const TOOLS: ToolDefinition[] = [ zodSchema: storeConversationSchema, inputSchema: jsonSchema(storeConversationSchema), handler: async (c, args: z.infer) => { - const result = await conversationsService.createConversation(c.get("db"), { - projectId: c.get("projectId"), - externalId: args.external_id ?? null, - title: args.title ?? null, - metadata: args.metadata ?? null, - inputMessages: args.messages, - }); + const result = await conversationsService.createConversation( + c.get("db"), + { + projectId: c.get("projectId"), + externalId: args.external_id ?? null, + title: args.title ?? null, + metadata: args.metadata ?? null, + inputMessages: args.messages, + }, + c.executionCtx, + c.env.AUTH_CACHE, + ); if (result.error) throw new ToolError(result.error.code, result.error.message); return { id: result.conversationId, diff --git a/packages/api/src/services/mcp-conversations.ts b/packages/api/src/services/mcp-conversations.ts index 320156e2..67a9d9b7 100644 --- a/packages/api/src/services/mcp-conversations.ts +++ b/packages/api/src/services/mcp-conversations.ts @@ -10,7 +10,10 @@ import { and, asc, desc, eq, gt, lt, sql } from "drizzle-orm"; import type { DrizzleD1Database } from "drizzle-orm/d1"; +// Hono's type, not the workers-types v5 global — see services/conversations.ts. +import type { ExecutionContext } from "hono"; import { conversations, conversationTags, messages } from "../db/schema"; +import { invalidateAnalyticsCache } from "../lib/analytics-cache"; import { CACHE_COUNT_TTL_S } from "../lib/config"; import { generateId } from "../lib/id"; import { serializeMetadata } from "../lib/serialization"; @@ -118,6 +121,8 @@ export function validateCursor( export async function createConversation( db: DrizzleD1Database, input: CreateConversationInput, + executionCtx: ExecutionContext, + cache?: KVNamespace, ): Promise { const { projectId, externalId, title, metadata, inputMessages } = input; const now = Date.now(); @@ -188,6 +193,8 @@ export async function createConversation( await db.insert(messages).values(rows); } + invalidateAnalyticsCache(cache, executionCtx, projectId); + return { conversationId, projectId, @@ -340,6 +347,9 @@ export async function updateConversation( export async function deleteConversation( db: DrizzleD1Database, conversationId: string, + projectId: string, + executionCtx: ExecutionContext, + cache?: KVNamespace, ): Promise { // Batch delete: tags → messages → conversation (order respects FK constraints) await db.batch([ @@ -347,4 +357,6 @@ export async function deleteConversation( db.delete(messages).where(eq(messages.conversationId, conversationId)), db.delete(conversations).where(eq(conversations.id, conversationId)), ]); + + invalidateAnalyticsCache(cache, executionCtx, projectId); } diff --git a/packages/api/test/analytics.test.ts b/packages/api/test/analytics.test.ts index 9825a7cd..70c5b27b 100644 --- a/packages/api/test/analytics.test.ts +++ b/packages/api/test/analytics.test.ts @@ -161,6 +161,56 @@ describe("Analytics", () => { expect(recentIds).toContain(created.id); }); + it("counts match after MCP store_conversation, with no stale cache (issue #372)", async () => { + const baselineRes = await fetchAnalytics(); + const baseline = await baselineRes.json(); + const baseConvs = baseline.summary.total_conversations; + const baseMsgs = baseline.summary.total_messages; + const baseTokens = baseline.summary.total_tokens; + const mcpMessages = [ + { role: "user", content: "Hello", token_count: 4 }, + { role: "assistant", content: "Hi", token_count: 6 }, + ]; + + const store = await SELF.fetch("http://localhost/api/mcp", { + method: "POST", + headers: { + ...authHeaders(), + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "store_conversation", + arguments: { + title: "MCP Analytics Cache Test", + messages: mcpMessages, + }, + }, + }), + }); + expect(store.status).toBe(200); + const storeJson = (await store.json()) as { + result?: { isError?: boolean; content: Array<{ text: string }> }; + }; + expect(storeJson.result?.isError).toBeUndefined(); + const created = JSON.parse(storeJson.result?.content[0].text ?? "{}") as { id?: string }; + expect(created.id).toBeTruthy(); + + const afterRes = await fetchAnalytics(); + const after = await afterRes.json(); + + expect(after.summary.total_conversations).toBe(baseConvs + 1); + expect(after.summary.total_messages).toBe(baseMsgs + mcpMessages.length); + const expectedTokenDelta = mcpMessages.reduce( + (sum, message) => sum + (message.token_count ?? 0), + 0, + ); + expect(after.summary.total_tokens).toBe(baseTokens + expectedTokenDelta); + }); + it("reflects deleted conversations immediately, with no stale cache (issue #352)", async () => { const createRes = await createConversation({ title: "To Be Deleted",