From a6771599afb1bf546fd9af044e570a445997a57f Mon Sep 17 00:00:00 2001 From: MikePfunk28 Date: Thu, 12 Feb 2026 15:39:04 -0500 Subject: [PATCH 1/3] feat: Enhance token usage tracking and error handling across workflows and billing processes - Improved user ID handling for token billing in multiple workflows. - Added non-fatal error handling for billing failures in token usage tracking. - Enhanced customer resolution logic for Stripe disputes. - Updated user ID types for Bedrock access control to ensure type safety. --- convex/agentBuilderWorkflow.ts | 2 +- convex/http.ts | 41 +++++++++++++++++++++----- convex/interleavedReasoning.ts | 23 ++++++++++----- convex/lib/bedrockGate.ts | 16 ++++++++-- convex/mcpClient.ts | 8 ++++- convex/promptChainExecutor.ts | 8 +++-- convex/strandsAgentExecution.ts | 23 ++++++++++----- convex/strandsAgentExecutionDynamic.ts | 30 +++++++++++++------ convex/unifiedAgentExecution.ts | 31 ++++++++++++++----- convex/workflowExecutor.ts | 13 ++++---- 10 files changed, 142 insertions(+), 53 deletions(-) diff --git a/convex/agentBuilderWorkflow.ts b/convex/agentBuilderWorkflow.ts index e65b29a..f3fa901 100644 --- a/convex/agentBuilderWorkflow.ts +++ b/convex/agentBuilderWorkflow.ts @@ -223,7 +223,7 @@ export const executeWorkflowStage = action( { // Meter: token-based billing for this workflow stage if ( result.inputTokens > 0 || result.outputTokens > 0 ) { await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { - userId: gateResult.userId as any, + userId: gateResult.userId, modelId: WORKFLOW_MODEL_ID, inputTokens: result.inputTokens, outputTokens: result.outputTokens, diff --git a/convex/http.ts b/convex/http.ts index 52ee3f9..05806e0 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -517,19 +517,44 @@ http.route({ } case "charge.dispute.created": { - // Stripe Dispute: customer is on the linked charge, not top-level. - // Use the raw event data which includes customer as a string. + // Stripe Dispute: customer may be top-level, on expanded charge, or + // require a charge lookup when charge is a string ID (not expanded). const disputeData = event.data.object as any; - const disputeCustomer: string | undefined = - typeof disputeData.customer === "string" - ? disputeData.customer - : typeof disputeData.charge === "string" - ? undefined // charge ID only — need to look up; skip for now - : disputeData.charge?.customer; + let disputeCustomer: string | undefined; + + if ( typeof disputeData.customer === "string" ) { + // Customer is directly on the dispute object + disputeCustomer = disputeData.customer; + } else if ( typeof disputeData.charge === "string" ) { + // Charge is an unexpanded string ID — fetch it to get customer + try { + const chargeObj = await stripe.charges.retrieve( disputeData.charge ); + disputeCustomer = typeof chargeObj.customer === "string" + ? chargeObj.customer + : typeof chargeObj.customer === "object" && chargeObj.customer !== null + ? chargeObj.customer.id + : undefined; + } catch ( chargeErr ) { + console.error( "Failed to retrieve charge for dispute", { + chargeId: disputeData.charge, + error: chargeErr instanceof Error ? chargeErr.message : chargeErr, + } ); + } + } else if ( disputeData.charge?.customer ) { + // Charge is an expanded object with customer + disputeCustomer = typeof disputeData.charge.customer === "string" + ? disputeData.charge.customer + : disputeData.charge.customer?.id; + } + if ( disputeCustomer ) { await ctx.runMutation(internal.stripeMutations.restrictAccountForDispute, { stripeCustomerId: disputeCustomer, }); + } else { + console.error( "charge.dispute.created: could not resolve customer", { + disputeId: disputeData.id, + } ); } break; } diff --git a/convex/interleavedReasoning.ts b/convex/interleavedReasoning.ts index 39d4da4..65b077b 100644 --- a/convex/interleavedReasoning.ts +++ b/convex/interleavedReasoning.ts @@ -145,14 +145,23 @@ export const sendMessage: any = action( { args.message ); - // Meter token usage for billing + // Meter token usage for billing (non-fatal: don't kill message delivery) if ( response.tokenUsage && gateResult.allowed ) { - await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { - userId: gateResult.userId as any, - modelId, - inputTokens: response.tokenUsage.inputTokens, - outputTokens: response.tokenUsage.outputTokens, - } ); + try { + await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { + userId: gateResult.userId, + modelId, + inputTokens: response.tokenUsage.inputTokens, + outputTokens: response.tokenUsage.outputTokens, + } ); + } catch ( billingErr ) { + console.error( "interleavedReasoning: billing failed (non-fatal)", { + userId: gateResult.userId, modelId, + inputTokens: response.tokenUsage.inputTokens, + outputTokens: response.tokenUsage.outputTokens, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } } } diff --git a/convex/lib/bedrockGate.ts b/convex/lib/bedrockGate.ts index 80b52ab..c253734 100644 --- a/convex/lib/bedrockGate.ts +++ b/convex/lib/bedrockGate.ts @@ -13,6 +13,7 @@ */ import { getAuthUserId } from "@convex-dev/auth/server"; +import type { Id } from "../_generated/dataModel"; import { isProviderAllowedForTier, isBedrockModelAllowedForTier, @@ -26,7 +27,7 @@ import { export interface BedrockAccessGranted { allowed: true; - userId: string; + userId: Id<"users">; tier: TierName; } @@ -82,7 +83,7 @@ export async function requireBedrockAccess( const result = await requireBedrockAccessForUser( user, modelId ); if ( result.allowed ) { // Override userId with the real authenticated user ID - return { ...result, userId: String( userId ) }; + return { ...result, userId }; } return result; } @@ -210,9 +211,18 @@ export async function requireBedrockAccessForUser( }; } + if ( !userDoc._id ) { + console.error( "bedrockGate: userDoc is missing _id", { tier, isAnonymous: userDoc.isAnonymous } ); + return { + allowed: false, + reason: "User record is incomplete (missing ID). Please sign in again.", + upgradeMessage: "Sign in to continue.", + }; + } + return { allowed: true, - userId: String( userDoc._id || "internal" ), + userId: userDoc._id as Id<"users">, tier, }; } diff --git a/convex/mcpClient.ts b/convex/mcpClient.ts index 8694fac..f7b7a71 100644 --- a/convex/mcpClient.ts +++ b/convex/mcpClient.ts @@ -629,7 +629,13 @@ async function invokeBedrockDirect(parameters: any, timeout: number): Promise `${msg.role}: ${msg.content}` ), + input, + ].filter( Boolean ).join( "\n" ); + tokenUsage = estimateTokenUsage( fullInputText, responseText ); } return { diff --git a/convex/promptChainExecutor.ts b/convex/promptChainExecutor.ts index fafabc2..136c29d 100644 --- a/convex/promptChainExecutor.ts +++ b/convex/promptChainExecutor.ts @@ -43,7 +43,7 @@ export const executePromptChain = action({ }> => { // Gate: enforce tier-based Bedrock access if any prompt uses a Bedrock model const hasBedrock = args.prompts.some( ( p ) => p.model.startsWith( "bedrock:" ) ); - let gateUserId: any = null; + let gateUserId: import("./_generated/dataModel").Id<"users"> | null = null; let gateModelId: string | undefined; if ( hasBedrock ) { const { requireBedrockAccess } = await import( "./lib/bedrockGate" ); @@ -173,10 +173,11 @@ export const executeParallelPrompts = action({ error?: string; }>; totalLatency: number; + error?: string; }> => { // Gate: enforce tier-based Bedrock access if any prompt uses a Bedrock model const hasBedrock = args.prompts.some( ( p ) => p.model.startsWith( "bedrock:" ) ); - let gateUserId: any = null; + let gateUserId: import("./_generated/dataModel").Id<"users"> | null = null; let gateModelId: string | undefined; if ( hasBedrock ) { const { requireBedrockAccess } = await import( "./lib/bedrockGate" ); @@ -191,6 +192,7 @@ export const executeParallelPrompts = action({ success: false, results: [], totalLatency: 0, + error: gateResult.reason, }; } gateUserId = gateResult.userId; @@ -431,7 +433,7 @@ export const testPrompt = action({ error?: string; }> => { // Gate: enforce tier-based Bedrock access - let gateUserId: any = null; + let gateUserId: import("./_generated/dataModel").Id<"users"> | null = null; let gateModelId: string | undefined; if ( args.model.startsWith( "bedrock:" ) ) { const { requireBedrockAccess } = await import( "./lib/bedrockGate" ); diff --git a/convex/strandsAgentExecution.ts b/convex/strandsAgentExecution.ts index bf66fc4..2a3137d 100644 --- a/convex/strandsAgentExecution.ts +++ b/convex/strandsAgentExecution.ts @@ -143,14 +143,23 @@ export const executeAgentWithStrandsAgents = action( { const result = await executeViaAgentCore( ctx, agent, args.message, history ); - // ─── Token-based metering ─────────────────────────────────────────── + // ─── Token-based metering (non-fatal) ──────────────────────────────── if ( result.tokenUsage ) { - await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { - userId: agent.createdBy, - modelId: agent.model, - inputTokens: result.tokenUsage.inputTokens, - outputTokens: result.tokenUsage.outputTokens, - } ); + try { + await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { + userId: agent.createdBy, + modelId: agent.model, + inputTokens: result.tokenUsage.inputTokens, + outputTokens: result.tokenUsage.outputTokens, + } ); + } catch ( billingErr ) { + console.error( "strandsAgentExecution: billing failed (non-fatal)", { + userId: agent.createdBy, modelId: agent.model, + inputTokens: result.tokenUsage.inputTokens, + outputTokens: result.tokenUsage.outputTokens, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } } return result; diff --git a/convex/strandsAgentExecutionDynamic.ts b/convex/strandsAgentExecutionDynamic.ts index 1605d74..5c90722 100644 --- a/convex/strandsAgentExecutionDynamic.ts +++ b/convex/strandsAgentExecutionDynamic.ts @@ -162,14 +162,24 @@ export const executeAgentWithDynamicModel = action( { } ); } - // ─── Token-based metering ─────────────────────────────────────────── + // ─── Token-based metering (non-fatal) ──────────────────────────────── if ( result.tokenUsage ) { - await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { - userId: agent.createdBy, - modelId: agent.model, - inputTokens: result.tokenUsage.inputTokens, - outputTokens: result.tokenUsage.outputTokens, - } ); + const executedModel = result.metadata?.model ?? agent.model; + try { + await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { + userId: agent.createdBy, + modelId: executedModel, + inputTokens: result.tokenUsage.inputTokens, + outputTokens: result.tokenUsage.outputTokens, + } ); + } catch ( billingErr ) { + console.error( "strandsAgentExecutionDynamic: billing failed (non-fatal)", { + userId: agent.createdBy, modelId: executedModel, + inputTokens: result.tokenUsage.inputTokens, + outputTokens: result.tokenUsage.outputTokens, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } } return result; @@ -388,9 +398,11 @@ async function executeDirectBedrock( const { extractTokenUsage, estimateTokenUsage } = await import( "./lib/tokenBilling" ); let tokenUsage = extractTokenUsage( responseBody, modelId ); - // Fallback: estimate from text when provider doesn't return counts + // Fallback: estimate from user-facing text (not full JSON payload which inflates counts) if ( tokenUsage.totalTokens === 0 ) { - const inputText = JSON.stringify( payload ); + const systemText = agent.systemPrompt || ""; + const historyText = history.map( ( m ) => m.content ).join( "\n" ); + const inputText = [systemText, historyText, message].filter( Boolean ).join( "\n" ); tokenUsage = estimateTokenUsage( inputText, content ); } diff --git a/convex/unifiedAgentExecution.ts b/convex/unifiedAgentExecution.ts index 4b5ae9b..3c0968e 100644 --- a/convex/unifiedAgentExecution.ts +++ b/convex/unifiedAgentExecution.ts @@ -97,6 +97,14 @@ export const executeUnifiedAgent = action({ const isBedrock = agent.deploymentType === "bedrock" || ( !agent.deploymentType && /^(us\.|eu\.|apac\.|global\.)?(anthropic|amazon|meta|mistral|cohere|ai21|deepseek|moonshot)\./.test( agent.model ) ); if ( isBedrock ) { + if ( !user ) { + return { + success: false, + modality: "text", + content: "", + error: "User record not found for agent owner. Cannot verify Bedrock access.", + }; + } const { requireBedrockAccessForUser } = await import( "./lib/bedrockGate" ); const gateResult = await requireBedrockAccessForUser( user, agent.model ); if ( !gateResult.allowed ) { @@ -105,7 +113,7 @@ export const executeUnifiedAgent = action({ modality: "text", content: "", error: gateResult.reason, - } as UnifiedExecutionResult; + }; } } @@ -246,12 +254,21 @@ async function executeText( tokenUsage = estimateTokenUsage( message, content ); } if ( tokenUsage.inputTokens > 0 || tokenUsage.outputTokens > 0 ) { - await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { - userId: agent.createdBy as any, - modelId: config.modelId, - inputTokens: tokenUsage.inputTokens, - outputTokens: tokenUsage.outputTokens, - } ); + try { + await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { + userId: agent.createdBy, + modelId: config.modelId, + inputTokens: tokenUsage.inputTokens, + outputTokens: tokenUsage.outputTokens, + } ); + } catch ( billingErr ) { + console.error( "unifiedAgentExecution: billing failed (non-fatal)", { + userId: agent.createdBy, modelId: config.modelId, + inputTokens: tokenUsage.inputTokens, + outputTokens: tokenUsage.outputTokens, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } } return { diff --git a/convex/workflowExecutor.ts b/convex/workflowExecutor.ts index a0cb5aa..37f18eb 100644 --- a/convex/workflowExecutor.ts +++ b/convex/workflowExecutor.ts @@ -25,15 +25,14 @@ async function getUserScope( ctx: any ): Promise { } // subject and tokenIdentifier are always unique per user in Convex auth. - // email is a reasonable fallback. Do NOT use identity.provider alone — - // it is just the provider name (e.g., "github") and is the same for all users. + // Do NOT use identity.email — emails can collide across providers. + // Do NOT use identity.provider alone — it is just the provider name. const scope = identity.subject || - identity.tokenIdentifier || - identity.email; + identity.tokenIdentifier; if ( !scope ) { - throw new Error( "Unable to resolve user identity." ); + throw new Error( "Unable to resolve stable user identity: subject and tokenIdentifier both missing." ); } return scope; @@ -164,7 +163,7 @@ async function executePromptModelWorkflow( } ); // Gate: enforce tier-based Bedrock access before executing - let gateResult: { allowed: true; userId: string; tier: string } | undefined; + let gateResult: { allowed: true; userId: import("./_generated/dataModel").Id<"users">; tier: string } | undefined; if ( composed.kind === "bedrock" ) { const { requireBedrockAccess } = await import( "./lib/bedrockGate" ); const gate = await requireBedrockAccess( @@ -184,7 +183,7 @@ async function executePromptModelWorkflow( // Meter token usage for billing if ( result.tokenUsage && gateResult ) { await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { - userId: gateResult.userId as any, + userId: gateResult.userId, modelId: composed.bedrock?.modelId, inputTokens: result.tokenUsage.inputTokens, outputTokens: result.tokenUsage.outputTokens, From 3bdf505cfcd2c3d22092ec2f1e2d9740b69b8574 Mon Sep 17 00:00:00 2001 From: MikePfunk28 Date: Thu, 12 Feb 2026 17:21:53 -0500 Subject: [PATCH 2/3] feat: Enhance non-fatal billing error handling and token usage metering across multiple components --- convex/agentBuilderWorkflow.ts | 22 ++++-- convex/automatedAgentBuilder.ts | 23 ++++-- convex/awsDeployment.ts | 23 +++--- convex/awsDiagramGenerator.ts | 2 +- convex/deploymentRouter.ts | 22 ++++-- convex/deployments.ts | 4 +- convex/mcpClient.ts | 54 +++++++++----- convex/mcpConfig.ts | 37 +++++++--- convex/promptChainExecutor.ts | 44 ++++++++---- convex/testExecution.ts | 4 +- convex/tools.ts | 120 +++++++++++++++++++++----------- convex/workflowExecutor.ts | 22 ++++-- 12 files changed, 252 insertions(+), 125 deletions(-) diff --git a/convex/agentBuilderWorkflow.ts b/convex/agentBuilderWorkflow.ts index f3fa901..02f4607 100644 --- a/convex/agentBuilderWorkflow.ts +++ b/convex/agentBuilderWorkflow.ts @@ -220,14 +220,22 @@ export const executeWorkflowStage = action( { userPrompt: fullPrompt, } ); - // Meter: token-based billing for this workflow stage + // Meter: token-based billing for this workflow stage (non-fatal) if ( result.inputTokens > 0 || result.outputTokens > 0 ) { - await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { - userId: gateResult.userId, - modelId: WORKFLOW_MODEL_ID, - inputTokens: result.inputTokens, - outputTokens: result.outputTokens, - } ); + try { + await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { + userId: gateResult.userId, + modelId: WORKFLOW_MODEL_ID, + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + } ); + } catch ( billingErr ) { + console.error( "agentBuilderWorkflow: billing failed (non-fatal)", { + userId: gateResult.userId, modelId: WORKFLOW_MODEL_ID, + inputTokens: result.inputTokens, outputTokens: result.outputTokens, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } } return { diff --git a/convex/automatedAgentBuilder.ts b/convex/automatedAgentBuilder.ts index 3e24a8c..4889fdd 100644 --- a/convex/automatedAgentBuilder.ts +++ b/convex/automatedAgentBuilder.ts @@ -154,14 +154,23 @@ export const processResponse = action( { const systemPrompt = buildSystemPrompt( session.agentRequirements ); const response = await analyzeAndAskNext( systemPrompt, updatedHistory ); - // Meter token usage for billing + // Meter token usage for billing (non-fatal: don't kill agent generation) if ( response.tokenUsage && gateResult.allowed ) { - await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { - userId: gateResult.userId as any, - modelId, - inputTokens: response.tokenUsage.inputTokens, - outputTokens: response.tokenUsage.outputTokens, - } ); + try { + await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { + userId: gateResult.userId, + modelId, + inputTokens: response.tokenUsage.inputTokens, + outputTokens: response.tokenUsage.outputTokens, + } ); + } catch ( billingErr ) { + console.error( "automatedAgentBuilder: billing failed (non-fatal)", { + userId: gateResult.userId, modelId, + inputTokens: response.tokenUsage.inputTokens, + outputTokens: response.tokenUsage.outputTokens, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } } // Parse response to extract: diff --git a/convex/awsDeployment.ts b/convex/awsDeployment.ts index 828c2d3..3916e20 100644 --- a/convex/awsDeployment.ts +++ b/convex/awsDeployment.ts @@ -7,6 +7,7 @@ import { action, internalAction, mutation, query, internalMutation, internalQuery } from "./_generated/server"; import { v } from "convex/values"; import { internal, api } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; // Stripe mutations live in stripeMutations.ts. Cast bridges codegen gap. @@ -703,7 +704,7 @@ async function deployToAgentCore( artifacts: any, config: any ) { export const createDeploymentInternal = internalMutation( { args: { agentId: v.id( "agents" ), - userId: v.union( v.id( "users" ), v.string() ), + userId: v.id( "users" ), tier: v.optional( v.string() ), deploymentConfig: v.object( { region: v.string(), @@ -714,14 +715,9 @@ export const createDeploymentInternal = internalMutation( { } ), }, handler: async ( ctx, args ) => { - // Ensure userId is a proper Id<"users"> - const userId = typeof args.userId === 'string' && args.userId.startsWith( 'j' ) - ? args.userId as any - : args.userId; - return await ctx.db.insert( "deployments", { agentId: args.agentId, - userId: userId, + userId: args.userId, tier: args.tier || "freemium", agentName: args.deploymentConfig.agentName, description: args.deploymentConfig.description, @@ -953,7 +949,7 @@ export const executeDeploymentInternal = internalAction( { /** * Tier 1: Deploy to YOUR Fargate (Freemium) */ -async function deployTier1( ctx: any, args: any, userId: string ): Promise { +async function deployTier1( ctx: any, args: any, userId: Id<"users"> ): Promise { // Create deployment record const deploymentId: any = await ctx.runMutation( internal.awsDeployment.createDeploymentInternal, { agentId: args.agentId, @@ -962,8 +958,15 @@ async function deployTier1( ctx: any, args: any, userId: string ): Promise deploymentConfig: args.deploymentConfig, } ); - // Increment usage counter (centralized in stripeMutations.ts) - await ctx.runMutation( internalStripeMutations.incrementUsageAndReportOverage, { userId } ); + // Increment usage counter (non-fatal: don't block deployment) + try { + await ctx.runMutation( internalStripeMutations.incrementUsageAndReportOverage, { userId } ); + } catch ( billingErr ) { + console.error( "awsDeployment.deployTier1: billing failed (non-fatal)", { + userId, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } // Start deployment await ctx.scheduler.runAfter( 0, internal.awsDeployment.executeDeploymentInternal, { diff --git a/convex/awsDiagramGenerator.ts b/convex/awsDiagramGenerator.ts index e071f56..8e4509a 100644 --- a/convex/awsDiagramGenerator.ts +++ b/convex/awsDiagramGenerator.ts @@ -365,7 +365,7 @@ export const storeDiagram = mutation({ // Create new diagram return await ctx.db.insert("diagrams", { deploymentId: args.deploymentId, - userId: userId as any, + userId: userId, format: args.format, content: args.content, generatedAt: Date.now(), diff --git a/convex/deploymentRouter.ts b/convex/deploymentRouter.ts index 227cc39..1da89d0 100644 --- a/convex/deploymentRouter.ts +++ b/convex/deploymentRouter.ts @@ -8,6 +8,7 @@ import { mutation, query } from "./_generated/server"; import { action } from "./_generated/server"; import { getAuthUserId } from "@convex-dev/auth/server"; import { api, internal } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; // Stripe mutations live in stripeMutations.ts. Cast bridges codegen gap. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -64,7 +65,7 @@ export const getUserTier = query({ }); // Tier 1: Deploy to AgentCore (Freemium) -async function deployTier1(ctx: any, args: any, userId: any): Promise { +async function deployTier1(ctx: any, args: any, userId: Id<"users">): Promise { // Check usage limits const user = await ctx.runQuery(api.deploymentRouter.getUserTier); @@ -129,11 +130,18 @@ async function deployTier1(ctx: any, args: any, userId: any): Promise { throw new Error(result.error || "AgentCore deployment failed"); } - // Increment usage counter (centralized in stripeMutations.ts) - await ctx.runMutation( internalStripeMutations.incrementUsageAndReportOverage, { - userId, - modelId: agent.model, - } ); + // Increment usage counter (non-fatal: don't block deployment) + try { + await ctx.runMutation( internalStripeMutations.incrementUsageAndReportOverage, { + userId, + modelId: agent.model, + } ); + } catch ( billingErr ) { + console.error( "deploymentRouter.deployTier1: billing failed (non-fatal)", { + userId, modelId: agent.model, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } return { success: true, @@ -251,7 +259,7 @@ export const getDeploymentHistory = query({ let query = ctx.db .query("deployments") - .withIndex("by_user", (q) => q.eq("userId", userId as any)); + .withIndex("by_user", (q) => q.eq("userId", userId)); if (args.agentId) { query = ctx.db diff --git a/convex/deployments.ts b/convex/deployments.ts index 9d958f1..fba2466 100644 --- a/convex/deployments.ts +++ b/convex/deployments.ts @@ -22,7 +22,7 @@ export const create = mutation({ const deploymentId = await ctx.db.insert("deployments", { agentId: args.agentId, - userId: userId as any, + userId: userId, tier: args.tier, awsAccountId: args.awsAccountId, region: args.region, @@ -74,7 +74,7 @@ export const list = query({ return await ctx.db .query("deployments") - .withIndex("by_user", (q) => q.eq("userId", userId as any)) + .withIndex("by_user", (q) => q.eq("userId", userId)) .order("desc") .take(args.limit || 20); }, diff --git a/convex/mcpClient.ts b/convex/mcpClient.ts index f7b7a71..4cf8032 100644 --- a/convex/mcpClient.ts +++ b/convex/mcpClient.ts @@ -3,6 +3,7 @@ import { action, internalAction } from "./_generated/server"; import { v } from "convex/values"; import { api, internal } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; /** * MCP Client for invoking tools from configured MCP servers @@ -131,19 +132,28 @@ export const invokeMCPToolInternal = internalAction({ const executionTime = Date.now() - startTime; - // Meter Bedrock usage if this was a direct Bedrock invocation with token data + // Meter Bedrock usage if this was a direct Bedrock invocation with token data (non-fatal) if ( result.success && args.userId && result.result?.tokenUsage && ( result.result.tokenUsage.inputTokens > 0 || result.result.tokenUsage.outputTokens > 0 ) ) { - await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { - userId: args.userId, - modelId: result.result.model_id, - inputTokens: result.result.tokenUsage.inputTokens, - outputTokens: result.result.tokenUsage.outputTokens, - } ); + try { + await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { + userId: args.userId, + modelId: result.result.model_id, + inputTokens: result.result.tokenUsage.inputTokens, + outputTokens: result.result.tokenUsage.outputTokens, + } ); + } catch ( billingErr ) { + console.error( "mcpClient: billing failed (non-fatal)", { + userId: args.userId, modelId: result.result.model_id, + inputTokens: result.result.tokenUsage.inputTokens, + outputTokens: result.result.tokenUsage.outputTokens, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } } // Return properly typed result @@ -240,13 +250,19 @@ export const invokeMCPTool = action({ const executionTime = Date.now() - startTime; + // Built-in servers (e.g. "system_ollama") have string _id, not Id<"mcpServers">. + // Only update status for real DB-backed servers. + const isDbServer = typeof server._id === "string" && !server._id.startsWith( "system_" ); + if (result.success) { // Update server status on successful invocation - await ctx.runMutation(api.mcpConfig.updateMCPServerStatus, { - serverId: server._id, - status: "connected", - lastConnected: Date.now(), - }); + if ( isDbServer ) { + await ctx.runMutation(api.mcpConfig.updateMCPServerStatus, { + serverId: server._id as Id<"mcpServers">, + status: "connected", + lastConnected: Date.now(), + }); + } // Log successful invocation await ctx.runMutation(api.errorLogging.logAuditEvent, { @@ -282,12 +298,14 @@ export const invokeMCPTool = action({ }, }); - // Update server status on failure - await ctx.runMutation(api.mcpConfig.updateMCPServerStatus, { - serverId: server._id, - status: "error", - lastError: result.error, - }); + // Update server status on failure (skip built-in servers) + if ( isDbServer ) { + await ctx.runMutation(api.mcpConfig.updateMCPServerStatus, { + serverId: server._id as Id<"mcpServers">, + status: "error", + lastError: result.error, + }); + } } // Return properly typed result with discriminated union diff --git a/convex/mcpConfig.ts b/convex/mcpConfig.ts index 5f0e896..cac4d57 100644 --- a/convex/mcpConfig.ts +++ b/convex/mcpConfig.ts @@ -16,14 +16,31 @@ import { getAuthUserId } from "@convex-dev/auth/server"; */ /** - * Built-in MCP servers available to all users + * Built-in MCP servers available to all users. + * These are in-memory objects (not from DB) with synthetic IDs and "system" userId. */ -const BUILT_IN_MCP_SERVERS = [ +interface BuiltInMcpServer { + _id: string; + _creationTime: number; + name: string; + userId: string; // "system" — not a real user ID + command: string; + args: string[]; + env: Record; + disabled: boolean; + timeout: number; + status: string; + availableTools: Array<{ name: string; description: string }>; + createdAt: number; + updatedAt: number; +} + +const BUILT_IN_MCP_SERVERS: BuiltInMcpServer[] = [ { - _id: "system_bedrock_agentcore" as any, + _id: "system_bedrock_agentcore", _creationTime: Date.now(), name: "bedrock-agentcore-mcp-server", - userId: "system" as any, + userId: "system", command: "bedrock-agentcore", args: [], env: {}, @@ -38,10 +55,10 @@ const BUILT_IN_MCP_SERVERS = [ }, { - _id: "system_document_fetcher" as any, + _id: "system_document_fetcher", _creationTime: Date.now(), name: "document-fetcher-mcp-server", - userId: "system" as any, + userId: "system", command: "uvx", args: ["mcp-document-fetcher"], env: {}, @@ -57,10 +74,10 @@ const BUILT_IN_MCP_SERVERS = [ updatedAt: Date.now(), }, { - _id: "system_aws_diagram" as any, + _id: "system_aws_diagram", _creationTime: Date.now(), name: "aws-diagram-mcp-server", - userId: "system" as any, + userId: "system", command: "uvx", args: ["awslabs.aws-diagram-mcp-server@latest"], env: {}, @@ -75,10 +92,10 @@ const BUILT_IN_MCP_SERVERS = [ updatedAt: Date.now(), }, { - _id: "system_ollama" as any, + _id: "system_ollama", _creationTime: Date.now(), name: "ollama-mcp-server", - userId: "system" as any, + userId: "system", command: "node", args: [process.env.OLLAMA_MCP_PATH || ""], env: { diff --git a/convex/promptChainExecutor.ts b/convex/promptChainExecutor.ts index 136c29d..f66b623 100644 --- a/convex/promptChainExecutor.ts +++ b/convex/promptChainExecutor.ts @@ -119,14 +119,22 @@ export const executePromptChain = action({ } } - // Meter: token-based billing for the entire chain + // Meter: token-based billing for the entire chain (non-fatal) if ( gateUserId && ( totalInputTokens > 0 || totalOutputTokens > 0 ) ) { - await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { - userId: gateUserId, - modelId: gateModelId, - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - } ); + try { + await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { + userId: gateUserId, + modelId: gateModelId, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + } ); + } catch ( billingErr ) { + console.error( "promptChainExecutor: billing failed (non-fatal)", { + userId: gateUserId, modelId: gateModelId, + inputTokens: totalInputTokens, outputTokens: totalOutputTokens, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } } const totalLatency = Date.now() - startTime; @@ -457,14 +465,22 @@ export const testPrompt = action({ // Execute const modelResult = await invokeModel(args.model, renderedPrompt, ctx); - // Meter: token-based billing + // Meter: token-based billing (non-fatal) if ( gateUserId && ( modelResult.inputTokens > 0 || modelResult.outputTokens > 0 ) ) { - await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { - userId: gateUserId, - modelId: gateModelId, - inputTokens: modelResult.inputTokens, - outputTokens: modelResult.outputTokens, - } ); + try { + await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { + userId: gateUserId, + modelId: gateModelId, + inputTokens: modelResult.inputTokens, + outputTokens: modelResult.outputTokens, + } ); + } catch ( billingErr ) { + console.error( "promptChainExecutor: billing failed (non-fatal)", { + userId: gateUserId, modelId: gateModelId, + inputTokens: modelResult.inputTokens, outputTokens: modelResult.outputTokens, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } } return { diff --git a/convex/testExecution.ts b/convex/testExecution.ts index e529868..5b0cca3 100644 --- a/convex/testExecution.ts +++ b/convex/testExecution.ts @@ -190,7 +190,7 @@ export const submitTest = mutation({ // Create test execution record const testId = await ctx.db.insert("testExecutions", { agentId: args.agentId, - userId: effectiveUserId as any, + userId: effectiveUserId, testQuery: args.testQuery, agentCode: agent.generatedCode, requirements, @@ -445,7 +445,7 @@ export const retryTest = mutation({ // Create new test with same configuration const newTestId = await ctx.db.insert("testExecutions", { agentId: originalTest.agentId, - userId: effectiveUserId as any, + userId: effectiveUserId, testQuery: args.modifyQuery || originalTest.testQuery, agentCode: originalTest.agentCode, requirements: originalTest.requirements, diff --git a/convex/tools.ts b/convex/tools.ts index 919cf19..427e1e5 100644 --- a/convex/tools.ts +++ b/convex/tools.ts @@ -361,7 +361,7 @@ export const selfConsistency = action({ handler: async (ctx, args) => { // Gate: enforce tier-based Bedrock access for cloud models const isOllamaModel = args.model.includes(":") && !args.model.includes("."); - let gateResult: { allowed: true; userId: string; tier: string } | undefined; + let gateResult: { allowed: true; userId: import("./_generated/dataModel").Id<"users">; tier: string } | undefined; if (!isOllamaModel) { const { requireBedrockAccess } = await import("./lib/bedrockGate"); const gate = await requireBedrockAccess( @@ -398,14 +398,22 @@ export const selfConsistency = action({ } } - // Meter accumulated token usage + // Meter accumulated token usage (non-fatal: don't kill tool response) if ((totalInputTokens > 0 || totalOutputTokens > 0) && gateResult) { - await ctx.runMutation(internal.stripeMutations.incrementUsageAndReportOverage, { - userId: gateResult.userId as any, - modelId: args.model, - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - }); + try { + await ctx.runMutation(internal.stripeMutations.incrementUsageAndReportOverage, { + userId: gateResult.userId, + modelId: args.model, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + }); + } catch ( billingErr ) { + console.error( "tools: billing failed (non-fatal)", { + userId: gateResult.userId, modelId: args.model, + inputTokens: totalInputTokens, outputTokens: totalOutputTokens, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } } // Count votes @@ -442,7 +450,7 @@ export const treeOfThoughts = action({ handler: async (ctx, args) => { // Gate: enforce tier-based Bedrock access for cloud models const isOllamaModel = args.model.includes(":") && !args.model.includes("."); - let gateResult: { allowed: true; userId: string; tier: string } | undefined; + let gateResult: { allowed: true; userId: import("./_generated/dataModel").Id<"users">; tier: string } | undefined; if (!isOllamaModel) { const { requireBedrockAccess } = await import("./lib/bedrockGate"); const gate = await requireBedrockAccess( @@ -500,14 +508,22 @@ export const treeOfThoughts = action({ frontier = nextFrontier; } - // Meter accumulated token usage + // Meter accumulated token usage (non-fatal: don't kill tool response) if ((totalInputTokens > 0 || totalOutputTokens > 0) && gateResult) { - await ctx.runMutation(internal.stripeMutations.incrementUsageAndReportOverage, { - userId: gateResult.userId as any, - modelId: args.model, - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - }); + try { + await ctx.runMutation(internal.stripeMutations.incrementUsageAndReportOverage, { + userId: gateResult.userId, + modelId: args.model, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + }); + } catch ( billingErr ) { + console.error( "tools: billing failed (non-fatal)", { + userId: gateResult.userId, modelId: args.model, + inputTokens: totalInputTokens, outputTokens: totalOutputTokens, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } } return { @@ -533,7 +549,7 @@ export const reflexion = action({ handler: async (ctx, args) => { // Gate: enforce tier-based Bedrock access for cloud models const isOllamaModel = args.model.includes(":") && !args.model.includes("."); - let gateResult: { allowed: true; userId: string; tier: string } | undefined; + let gateResult: { allowed: true; userId: import("./_generated/dataModel").Id<"users">; tier: string } | undefined; if (!isOllamaModel) { const { requireBedrockAccess } = await import("./lib/bedrockGate"); const gate = await requireBedrockAccess( @@ -597,14 +613,22 @@ export const reflexion = action({ } } - // Meter accumulated token usage + // Meter accumulated token usage (non-fatal: don't kill tool response) if ((totalInputTokens > 0 || totalOutputTokens > 0) && gateResult) { - await ctx.runMutation(internal.stripeMutations.incrementUsageAndReportOverage, { - userId: gateResult.userId as any, - modelId: args.model, - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - }); + try { + await ctx.runMutation(internal.stripeMutations.incrementUsageAndReportOverage, { + userId: gateResult.userId, + modelId: args.model, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + }); + } catch ( billingErr ) { + console.error( "tools: billing failed (non-fatal)", { + userId: gateResult.userId, modelId: args.model, + inputTokens: totalInputTokens, outputTokens: totalOutputTokens, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } } return { @@ -631,7 +655,7 @@ export const mapReduce = action({ handler: async (ctx, args) => { // Gate: enforce tier-based Bedrock access for cloud models const isOllamaModel = args.model.includes(":") && !args.model.includes("."); - let gateResult: { allowed: true; userId: string; tier: string } | undefined; + let gateResult: { allowed: true; userId: import("./_generated/dataModel").Id<"users">; tier: string } | undefined; if (!isOllamaModel) { const { requireBedrockAccess } = await import("./lib/bedrockGate"); const gate = await requireBedrockAccess( @@ -689,14 +713,22 @@ export const mapReduce = action({ finalResult = `Reduce phase failed: ${error.message}. Intermediate: ${mapResults.join(" | ")}`; } - // Meter accumulated token usage + // Meter accumulated token usage (non-fatal: don't kill tool response) if ((totalInputTokens > 0 || totalOutputTokens > 0) && gateResult) { - await ctx.runMutation(internal.stripeMutations.incrementUsageAndReportOverage, { - userId: gateResult.userId as any, - modelId: args.model, - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - }); + try { + await ctx.runMutation(internal.stripeMutations.incrementUsageAndReportOverage, { + userId: gateResult.userId, + modelId: args.model, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + }); + } catch ( billingErr ) { + console.error( "tools: billing failed (non-fatal)", { + userId: gateResult.userId, modelId: args.model, + inputTokens: totalInputTokens, outputTokens: totalOutputTokens, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } } return { @@ -726,7 +758,7 @@ export const parallelPrompts = action({ handler: async (ctx, args) => { // Gate: enforce tier-based Bedrock access for cloud models const isOllamaModel = args.model.includes(":") && !args.model.includes("."); - let gateResult: { allowed: true; userId: string; tier: string } | undefined; + let gateResult: { allowed: true; userId: import("./_generated/dataModel").Id<"users">; tier: string } | undefined; if (!isOllamaModel) { const { requireBedrockAccess } = await import("./lib/bedrockGate"); const gate = await requireBedrockAccess( @@ -773,14 +805,22 @@ export const parallelPrompts = action({ }); } - // Meter accumulated token usage + // Meter accumulated token usage (non-fatal: don't kill tool response) if ((totalInputTokens > 0 || totalOutputTokens > 0) && gateResult) { - await ctx.runMutation(internal.stripeMutations.incrementUsageAndReportOverage, { - userId: gateResult.userId as any, - modelId: args.model, - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - }); + try { + await ctx.runMutation(internal.stripeMutations.incrementUsageAndReportOverage, { + userId: gateResult.userId, + modelId: args.model, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + }); + } catch ( billingErr ) { + console.error( "tools: billing failed (non-fatal)", { + userId: gateResult.userId, modelId: args.model, + inputTokens: totalInputTokens, outputTokens: totalOutputTokens, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } } return { diff --git a/convex/workflowExecutor.ts b/convex/workflowExecutor.ts index 37f18eb..ba2e79e 100644 --- a/convex/workflowExecutor.ts +++ b/convex/workflowExecutor.ts @@ -180,14 +180,22 @@ async function executePromptModelWorkflow( // Execute composed messages with actual API calls const result = await executeComposedMessages( composed ); - // Meter token usage for billing + // Meter token usage for billing (non-fatal: don't kill workflow execution) if ( result.tokenUsage && gateResult ) { - await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { - userId: gateResult.userId, - modelId: composed.bedrock?.modelId, - inputTokens: result.tokenUsage.inputTokens, - outputTokens: result.tokenUsage.outputTokens, - } ); + try { + await ctx.runMutation( internal.stripeMutations.incrementUsageAndReportOverage, { + userId: gateResult.userId, + modelId: composed.bedrock?.modelId, + inputTokens: result.tokenUsage.inputTokens, + outputTokens: result.tokenUsage.outputTokens, + } ); + } catch ( billingErr ) { + console.error( "workflowExecutor: billing failed (non-fatal)", { + userId: gateResult.userId, modelId: composed.bedrock?.modelId, + inputTokens: result.tokenUsage.inputTokens, outputTokens: result.tokenUsage.outputTokens, + error: billingErr instanceof Error ? billingErr.message : billingErr, + } ); + } } return { From 9a8e7cde05dc362a2542aa1ddef74eb470114389 Mon Sep 17 00:00:00 2001 From: MikePfunk28 Date: Thu, 12 Feb 2026 18:14:51 -0500 Subject: [PATCH 3/3] feat: Enhance MCP server management with source differentiation - Introduced a `source` property to distinguish between built-in and user-defined MCP servers. - Updated interfaces and types across the MCP server management codebase to accommodate the new `source` property. - Modified the `listMCPServers` query to include the `source` attribute for user-defined servers. - Adjusted the MCP management panel to conditionally render action buttons based on the server's source. - Ensured compatibility with both string synthetic IDs for built-in servers and Convex IDs for user-defined servers. - Updated forms and selectors to handle the new `source` property and maintain type safety. --- .../setup-github-projects_Version3.yml | 9 +- convex/mcpClient.ts | 348 +++++++++--------- convex/mcpConfig.ts | 323 +++++++++------- src/components/MCPManagementPanel.tsx | 59 +-- src/components/MCPServerForm.tsx | 3 +- src/components/MCPServerSelector.tsx | 5 +- src/components/MCPToolTester.tsx | 17 +- 7 files changed, 408 insertions(+), 356 deletions(-) diff --git a/.github/workflows/setup-github-projects_Version3.yml b/.github/workflows/setup-github-projects_Version3.yml index a99d2f3..7eb9b79 100644 --- a/.github/workflows/setup-github-projects_Version3.yml +++ b/.github/workflows/setup-github-projects_Version3.yml @@ -32,17 +32,12 @@ jobs: exit 1 fi - - name: Authenticate GH CLI - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh auth login --with-token <<< "${GH_TOKEN}" - - name: Create Project with description env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - PROJECT_NAME="Auto Project" - PROJECT_DESC="Automated project for task management with weekly iterations" + PROJECT_NAME="Agent Builder Application" + PROJECT_DESC="Agent Builder Application - AI agent creation, testing, and deployment platform" # Check if project exists proj_list=$(gh project list --owner ${{ github.repository_owner }} --format json 2>&1) diff --git a/convex/mcpClient.ts b/convex/mcpClient.ts index 4cf8032..8e2f659 100644 --- a/convex/mcpClient.ts +++ b/convex/mcpClient.ts @@ -3,6 +3,7 @@ import { action, internalAction } from "./_generated/server"; import { v } from "convex/values"; import { api, internal } from "./_generated/api"; +import { isDbMcpServer } from "./mcpConfig"; import type { Id } from "./_generated/dataModel"; /** @@ -21,14 +22,14 @@ import type { Id } from "./_generated/dataModel"; */ export type MCPToolResult = | { - success: true; - result: any; - executionTime: number; - } + success: true; + result: any; + executionTime: number; + } | { - success: false; - error: string; - }; + success: false; + error: string; + }; /** * @deprecated Use MCPToolResult instead @@ -57,8 +58,8 @@ const DEFAULT_RETRY_CONFIG: RetryConfig = { /** * Sleep utility for retry delays */ -async function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); +async function sleep( ms: number ): Promise { + return new Promise( resolve => setTimeout( resolve, ms ) ); } /** @@ -68,13 +69,13 @@ function calculateBackoffDelay( attempt: number, config: RetryConfig ): number { - const delay = config.initialDelayMs * Math.pow(config.backoffMultiplier, attempt); - return Math.min(delay, config.maxDelayMs); + const delay = config.initialDelayMs * Math.pow( config.backoffMultiplier, attempt ); + return Math.min( delay, config.maxDelayMs ); } /** * Invoke an MCP tool with retry logic and error handling - * + * * This action communicates with MCP servers to invoke tools. * It includes: * - Connection management @@ -86,32 +87,32 @@ function calculateBackoffDelay( * Internal MCP tool invocation (no auth required) * Used by system actions like queue processor */ -export const invokeMCPToolInternal = internalAction({ +export const invokeMCPToolInternal = internalAction( { args: { serverName: v.string(), toolName: v.string(), parameters: v.any(), - userId: v.optional(v.id("users")), - timeout: v.optional(v.number()), + userId: v.optional( v.id( "users" ) ), + timeout: v.optional( v.number() ), }, - handler: async (ctx, args) => { + handler: async ( ctx, args ) => { const startTime = Date.now(); try { // Get MCP server configuration from database (internal query) - const server = await ctx.runQuery(internal.mcpConfig.getMCPServerByNameInternal, { + const server = await ctx.runQuery( internal.mcpConfig.getMCPServerByNameInternal, { serverName: args.serverName, userId: args.userId, - }); + } ); - if (!server) { + if ( !server ) { return { success: false, error: `MCP server "${args.serverName}" not found.`, }; } - if (server.disabled) { + if ( server.disabled ) { return { success: false, error: `MCP server "${args.serverName}" is disabled.`, @@ -157,7 +158,7 @@ export const invokeMCPToolInternal = internalAction({ } // Return properly typed result - if (result.success) { + if ( result.success ) { return { success: true, result: result.result, @@ -169,34 +170,34 @@ export const invokeMCPToolInternal = internalAction({ error: result.error, }; } - } catch (error: any) { + } catch ( error: any ) { return { success: false, - error: `Failed to invoke MCP tool: ${error.message || String(error)}`, + error: `Failed to invoke MCP tool: ${error.message || String( error )}`, }; } }, -}); +} ); -export const invokeMCPTool = action({ +export const invokeMCPTool = action( { args: { serverName: v.string(), toolName: v.string(), parameters: v.any(), - timeout: v.optional(v.number()), // Override timeout in milliseconds + timeout: v.optional( v.number() ), // Override timeout in milliseconds }, - handler: async (ctx, args) => { + handler: async ( ctx, args ) => { const startTime = Date.now(); try { // Get MCP server configuration from database - const server = await ctx.runQuery(api.mcpConfig.getMCPServerByName, { + const server = await ctx.runQuery( api.mcpConfig.getMCPServerByName, { serverName: args.serverName, - }); + } ); - if (!server) { + if ( !server ) { // Log error - await ctx.runMutation(api.errorLogging.logError, { + await ctx.runMutation( api.errorLogging.logError, { category: "mcp", severity: "error", message: `MCP server "${args.serverName}" not found`, @@ -207,7 +208,7 @@ export const invokeMCPTool = action({ metadata: { serverName: args.serverName, }, - }); + } ); return { success: false, @@ -215,9 +216,9 @@ export const invokeMCPTool = action({ }; } - if (server.disabled) { + if ( server.disabled ) { // Log warning - await ctx.runMutation(api.errorLogging.logError, { + await ctx.runMutation( api.errorLogging.logError, { category: "mcp", severity: "warning", message: `Attempted to invoke disabled MCP server "${args.serverName}"`, @@ -228,7 +229,7 @@ export const invokeMCPTool = action({ metadata: { serverName: args.serverName, }, - }); + } ); return { success: false, @@ -250,22 +251,19 @@ export const invokeMCPTool = action({ const executionTime = Date.now() - startTime; - // Built-in servers (e.g. "system_ollama") have string _id, not Id<"mcpServers">. - // Only update status for real DB-backed servers. - const isDbServer = typeof server._id === "string" && !server._id.startsWith( "system_" ); - - if (result.success) { + // Only update status for DB-backed servers — use the type guard exported from mcpConfig. + if ( result.success ) { // Update server status on successful invocation - if ( isDbServer ) { - await ctx.runMutation(api.mcpConfig.updateMCPServerStatus, { + if ( isDbMcpServer( server as any ) ) { + await ctx.runMutation( api.mcpConfig.updateMCPServerStatus, { serverId: server._id as Id<"mcpServers">, status: "connected", lastConnected: Date.now(), - }); + } ); } // Log successful invocation - await ctx.runMutation(api.errorLogging.logAuditEvent, { + await ctx.runMutation( api.errorLogging.logAuditEvent, { eventType: "mcp_invocation", action: `invoke_${args.toolName}`, resource: "mcp_tool", @@ -280,10 +278,10 @@ export const invokeMCPTool = action({ serverName: args.serverName, toolName: args.toolName, }, - }); + } ); } else { // Log failed invocation - await ctx.runMutation(api.errorLogging.logError, { + await ctx.runMutation( api.errorLogging.logError, { category: "mcp", severity: "error", message: `MCP tool invocation failed: ${args.serverName}/${args.toolName}`, @@ -296,20 +294,20 @@ export const invokeMCPTool = action({ metadata: { serverName: args.serverName, }, - }); + } ); - // Update server status on failure (skip built-in servers) - if ( isDbServer ) { - await ctx.runMutation(api.mcpConfig.updateMCPServerStatus, { + // Update server status on failure (only for DB-backed servers) + if ( isDbMcpServer( server as any ) ) { + await ctx.runMutation( api.mcpConfig.updateMCPServerStatus, { serverId: server._id as Id<"mcpServers">, status: "error", lastError: result.error, - }); + } ); } } // Return properly typed result with discriminated union - if (result.success) { + if ( result.success ) { return { success: true, result: result.result, @@ -321,34 +319,34 @@ export const invokeMCPTool = action({ error: result.error, }; } - } catch (error: any) { + } catch ( error: any ) { const executionTime = Date.now() - startTime; - + // Log exception - await ctx.runMutation(api.errorLogging.logError, { + await ctx.runMutation( api.errorLogging.logError, { category: "mcp", severity: "critical", message: `MCP tool invocation exception: ${args.serverName}/${args.toolName}`, details: { serverName: args.serverName, toolName: args.toolName, - error: error.message || String(error), + error: error.message || String( error ), executionTime, }, stackTrace: error.stack, metadata: { serverName: args.serverName, }, - }); + } ); return { success: false, - error: `Failed to invoke MCP tool: ${error.message || String(error)}`, + error: `Failed to invoke MCP tool: ${error.message || String( error )}`, executionTime, }; } }, -}); +} ); /** * Internal function to invoke MCP tool with retry logic @@ -359,15 +357,16 @@ async function invokeMCPToolWithRetry( parameters: any, timeout: number, retryConfig: RetryConfig -): Promise { +): Promise { let lastError: Error | null = null; + const startTime = Date.now(); - for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) { + for ( let attempt = 0; attempt <= retryConfig.maxRetries; attempt++ ) { try { // Add delay for retry attempts (not on first attempt) - if (attempt > 0) { - const delay = calculateBackoffDelay(attempt - 1, retryConfig); - await sleep(delay); + if ( attempt > 0 ) { + const delay = calculateBackoffDelay( attempt - 1, retryConfig ); + await sleep( delay ); } // Invoke the MCP tool @@ -381,23 +380,24 @@ async function invokeMCPToolWithRetry( return { success: true, result, + executionTime: Date.now() - startTime, }; - } catch (error: any) { + } catch ( error: any ) { lastError = error; // Check if error is retryable - if (!isRetryableError(error)) { + if ( !isRetryableError( error ) ) { return { success: false, - error: `Non-retryable error: ${error.message || String(error)}`, + error: `Non-retryable error: ${error.message || String( error )}`, }; } // If this was the last attempt, return the error - if (attempt === retryConfig.maxRetries) { + if ( attempt === retryConfig.maxRetries ) { return { success: false, - error: `Failed after ${retryConfig.maxRetries + 1} attempts: ${error.message || String(error)}`, + error: `Failed after ${retryConfig.maxRetries + 1} attempts: ${error.message || String( error )}`, }; } } @@ -422,26 +422,26 @@ async function invokeMCPToolDirect( timeout: number ): Promise { // Special handling for Bedrock AgentCore - if (server.name === "bedrock-agentcore-mcp-server" || toolName === "execute_agent") { - return await invokeBedrockAgentCore(parameters, timeout); + if ( server.name === "bedrock-agentcore-mcp-server" || toolName === "execute_agent" ) { + return await invokeBedrockAgentCore( parameters, timeout ); } // For other MCP servers, use MCP SDK - const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); - const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js"); + const { Client } = await import( "@modelcontextprotocol/sdk/client/index.js" ); + const { StdioClientTransport } = await import( "@modelcontextprotocol/sdk/client/stdio.js" ); let client: any = null; try { // Create stdio transport for the MCP server - const transport = new StdioClientTransport({ + const transport = new StdioClientTransport( { command: server.command, args: server.args || [], env: { ...process.env, - ...(server.env || {}), + ...( server.env || {} ), }, - }); + } ); // Create MCP client client = new Client( @@ -455,47 +455,47 @@ async function invokeMCPToolDirect( ); // Connect to the server with timeout - await Promise.race([ - client.connect(transport), - new Promise((_, reject) => - setTimeout(() => reject(new Error("MCP server connection timeout")), timeout) + await Promise.race( [ + client.connect( transport ), + new Promise( ( _, reject ) => + setTimeout( () => reject( new Error( "MCP server connection timeout" ) ), timeout ) ), - ]); + ] ); // List available tools const toolsList = await client.listTools(); // Find the requested tool - const tool = toolsList.tools.find((t: any) => t.name === toolName); - if (!tool) { + const tool = toolsList.tools.find( ( t: any ) => t.name === toolName ); + if ( !tool ) { throw new Error( - `Tool "${toolName}" not found. Available tools: ${toolsList.tools.map((t: any) => t.name).join(", ")}` + `Tool "${toolName}" not found. Available tools: ${toolsList.tools.map( ( t: any ) => t.name ).join( ", " )}` ); } // Call the tool with timeout - const result = await Promise.race([ - client.callTool({ + const result = await Promise.race( [ + client.callTool( { name: toolName, arguments: parameters, - }), - new Promise((_, reject) => - setTimeout(() => reject(new Error("MCP tool invocation timeout")), timeout) + } ), + new Promise( ( _, reject ) => + setTimeout( () => reject( new Error( "MCP tool invocation timeout" ) ), timeout ) ), - ]); + ] ); // Return the result content return result.content?.[0]?.text || result; - } catch (error: any) { - console.error(`MCP invocation error (${server.name}/${toolName}):`, error); + } catch ( error: any ) { + console.error( `MCP invocation error (${server.name}/${toolName}):`, error ); throw error; } finally { // Clean up: close the client connection - if (client) { + if ( client ) { try { await client.close(); - } catch (closeError) { - console.error("Error closing MCP client:", closeError); + } catch ( closeError ) { + console.error( "Error closing MCP client:", closeError ); } } } @@ -508,39 +508,39 @@ async function invokeMCPToolDirect( * Invoke Bedrock AgentCore MCP Runtime with Cognito JWT authentication * This calls the actual MCP Runtime HTTP endpoint deployed via CloudFormation */ -async function invokeBedrockAgentCore(parameters: any, timeout: number): Promise { +async function invokeBedrockAgentCore( parameters: any, timeout: number ): Promise { try { // Get MCP Runtime endpoint from environment const runtimeEndpoint = process.env.AGENTCORE_MCP_RUNTIME_ENDPOINT; - if (!runtimeEndpoint) { - console.warn("AGENTCORE_MCP_RUNTIME_ENDPOINT not set, falling back to direct Bedrock API"); - return await invokeBedrockDirect(parameters, timeout); + if ( !runtimeEndpoint ) { + console.warn( "AGENTCORE_MCP_RUNTIME_ENDPOINT not set, falling back to direct Bedrock API" ); + return await invokeBedrockDirect( parameters, timeout ); } // Get Cognito JWT token - const { api } = await import("./_generated/api.js"); + const { api } = await import( "./_generated/api.js" ); // Import action runner - this is a workaround since we're in a non-Convex context // In production, you'd inject the ctx or use a proper service - const tokenResult = await fetch(`${process.env.CONVEX_SITE_URL}/api/cognitoAuth/getCachedCognitoToken`, { + const tokenResult = await fetch( `${process.env.CONVEX_SITE_URL}/api/cognitoAuth/getCachedCognitoToken`, { method: "POST", headers: { "Content-Type": "application/json" }, - }).then(r => r.json()).catch(() => ({ success: false })); + } ).then( r => r.json() ).catch( () => ( { success: false } ) ); - if (!tokenResult.success || !tokenResult.token) { - throw new Error("Failed to get Cognito JWT token"); + if ( !tokenResult.success || !tokenResult.token ) { + throw new Error( "Failed to get Cognito JWT token" ); } // Make HTTP request to MCP Runtime endpoint - const response = await Promise.race([ - fetch(`${runtimeEndpoint}/mcp/invoke`, { + const response = await Promise.race( [ + fetch( `${runtimeEndpoint}/mcp/invoke`, { method: "POST", headers: { "Authorization": `Bearer ${tokenResult.token}`, "Content-Type": "application/json", }, - body: JSON.stringify({ + body: JSON.stringify( { tool: "execute_agent", parameters: { code: parameters.code, @@ -549,24 +549,24 @@ async function invokeBedrockAgentCore(parameters: any, timeout: number): Promise system_prompt: parameters.system_prompt, conversation_history: parameters.conversation_history || [], }, - }), - }), - new Promise((_, reject) => - setTimeout(() => reject(new Error("MCP Runtime invocation timeout")), timeout) + } ), + } ), + new Promise( ( _, reject ) => + setTimeout( () => reject( new Error( "MCP Runtime invocation timeout" ) ), timeout ) ), - ]); + ] ); - if (!response.ok) { + if ( !response.ok ) { const errorText = await response.text(); - throw new Error(`MCP Runtime returned ${response.status}: ${errorText}`); + throw new Error( `MCP Runtime returned ${response.status}: ${errorText}` ); } const result = await response.json(); return result; - } catch (error: any) { - console.error("Bedrock AgentCore MCP invocation failed:", error); - throw new Error(`MCP Runtime invocation failed: ${error.message}`); + } catch ( error: any ) { + console.error( "Bedrock AgentCore MCP invocation failed:", error ); + throw new Error( `MCP Runtime invocation failed: ${error.message}` ); } } @@ -574,9 +574,9 @@ async function invokeBedrockAgentCore(parameters: any, timeout: number): Promise * Fallback: Direct Bedrock API invocation (for when MCP Runtime is not configured) * @deprecated Use MCP Runtime instead */ -async function invokeBedrockDirect(parameters: any, timeout: number): Promise { +async function invokeBedrockDirect( parameters: any, timeout: number ): Promise { try { - const { BedrockRuntimeClient, InvokeModelCommand } = await import("@aws-sdk/client-bedrock-runtime"); + const { BedrockRuntimeClient, InvokeModelCommand } = await import( "@aws-sdk/client-bedrock-runtime" ); const accessKeyId = process.env.AWS_ACCESS_KEY_ID; const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; @@ -584,12 +584,12 @@ async function invokeBedrockDirect(parameters: any, timeout: number): Promise - setTimeout(() => reject(new Error("Bedrock invocation timeout")), timeout) + const response: any = await Promise.race( [ + client.send( command ), + new Promise( ( _, reject ) => + setTimeout( () => reject( new Error( "Bedrock invocation timeout" ) ), timeout ) ), - ]); + ] ); // Parse response - const responseBody = JSON.parse(new TextDecoder().decode(response.body)); + const responseBody = JSON.parse( new TextDecoder().decode( response.body ) ); // Extract text from response - const responseText = responseBody.content?.[0]?.text || JSON.stringify(responseBody); + const responseText = responseBody.content?.[0]?.text || JSON.stringify( responseBody ); // Extract token usage for metering const { extractTokenUsage, estimateTokenUsage } = await import( "./lib/tokenBilling" ); @@ -663,36 +663,36 @@ async function invokeBedrockDirect(parameters: any, timeout: number): Promise= 500) { + if ( error.statusCode && error.statusCode >= 500 ) { return true; } // Rate limiting errors are retryable - if (error.statusCode === 429) { + if ( error.statusCode === 429 ) { return true; } @@ -706,18 +706,18 @@ function isRetryableError(error: any): boolean { * This action attempts to connect to an MCP server and list its available tools. * It's useful for validating server configuration. */ -export const testMCPServerConnection = action({ +export const testMCPServerConnection = action( { args: { serverName: v.string(), }, - handler: async (ctx, args) => { + handler: async ( ctx, args ) => { try { // Get server configuration - const server = await ctx.runQuery(api.mcpConfig.getMCPServerByName, { + const server = await ctx.runQuery( api.mcpConfig.getMCPServerByName, { serverName: args.serverName, - }); + } ); - if (!server) { + if ( !server ) { return { success: false, status: "error", @@ -725,7 +725,7 @@ export const testMCPServerConnection = action({ }; } - if (server.disabled) { + if ( server.disabled ) { return { success: false, status: "disabled", @@ -734,8 +734,8 @@ export const testMCPServerConnection = action({ } // Special case for Bedrock AgentCore - it's always available if AWS creds are set - if (server.name === "bedrock-agentcore-mcp-server") { - if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { + if ( server.name === "bedrock-agentcore-mcp-server" ) { + if ( !process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY ) { return { success: false, status: "error", @@ -766,20 +766,20 @@ export const testMCPServerConnection = action({ } // For other MCP servers, connect and list tools - const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); - const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js"); + const { Client } = await import( "@modelcontextprotocol/sdk/client/index.js" ); + const { StdioClientTransport } = await import( "@modelcontextprotocol/sdk/client/stdio.js" ); let client: any = null; try { - const transport = new StdioClientTransport({ + const transport = new StdioClientTransport( { command: server.command, args: server.args || [], env: { ...process.env, - ...(server.env || {}), + ...( server.env || {} ), }, - }); + } ); client = new Client( { @@ -792,12 +792,12 @@ export const testMCPServerConnection = action({ ); // Connect with 10 second timeout - await Promise.race([ - client.connect(transport), - new Promise((_, reject) => - setTimeout(() => reject(new Error("Connection timeout")), 10000) + await Promise.race( [ + client.connect( transport ), + new Promise( ( _, reject ) => + setTimeout( () => reject( new Error( "Connection timeout" ) ), 10000 ) ), - ]); + ] ); // List available tools const toolsList = await client.listTools(); @@ -805,27 +805,27 @@ export const testMCPServerConnection = action({ return { success: true, status: "connected", - tools: toolsList.tools.map((tool: any) => ({ + tools: toolsList.tools.map( ( tool: any ) => ( { name: tool.name, description: tool.description, inputSchema: tool.inputSchema, - })), + } ) ), }; } finally { - if (client) { + if ( client ) { try { await client.close(); - } catch (closeError) { - console.error("Error closing test client:", closeError); + } catch ( closeError ) { + console.error( "Error closing test client:", closeError ); } } } - } catch (error: any) { + } catch ( error: any ) { return { success: false, status: "error", - error: error.message || String(error), + error: error.message || String( error ), }; } }, -}); +} ); diff --git a/convex/mcpConfig.ts b/convex/mcpConfig.ts index cac4d57..55c5aef 100644 --- a/convex/mcpConfig.ts +++ b/convex/mcpConfig.ts @@ -2,6 +2,7 @@ import { mutation, query, action, internalQuery } from "./_generated/server"; import { v } from "convex/values"; import { api } from "./_generated/api"; import { getAuthUserId } from "@convex-dev/auth/server"; +import type { Id } from "./_generated/dataModel"; /** * MCP Configuration Management @@ -20,7 +21,8 @@ import { getAuthUserId } from "@convex-dev/auth/server"; * These are in-memory objects (not from DB) with synthetic IDs and "system" userId. */ interface BuiltInMcpServer { - _id: string; + source: "system"; + _id: string; // synthetic id (e.g. "system_ollama") — not a DB id _creationTime: number; name: string; userId: string; // "system" — not a real user ID @@ -35,8 +37,28 @@ interface BuiltInMcpServer { updatedAt: number; } +interface DbMcpServer { + source: "user"; + _id: Id<"mcpServers">; + _creationTime: number; + name: string; + userId: Id<"users">; + command: string; + args: string[]; + env: Record; + disabled: boolean; + timeout: number; + status: string; + availableTools: Array<{ name: string; description: string }>; + createdAt: number; + updatedAt: number; +} + +export type MCPServerEntry = BuiltInMcpServer | DbMcpServer; + const BUILT_IN_MCP_SERVERS: BuiltInMcpServer[] = [ { + source: "system" as const, _id: "system_bedrock_agentcore", _creationTime: Date.now(), name: "bedrock-agentcore-mcp-server", @@ -55,6 +77,7 @@ const BUILT_IN_MCP_SERVERS: BuiltInMcpServer[] = [ }, { + source: "system" as const, _id: "system_document_fetcher", _creationTime: Date.now(), name: "document-fetcher-mcp-server", @@ -74,6 +97,7 @@ const BUILT_IN_MCP_SERVERS: BuiltInMcpServer[] = [ updatedAt: Date.now(), }, { + source: "system" as const, _id: "system_aws_diagram", _creationTime: Date.now(), name: "aws-diagram-mcp-server", @@ -92,6 +116,7 @@ const BUILT_IN_MCP_SERVERS: BuiltInMcpServer[] = [ updatedAt: Date.now(), }, { + source: "system" as const, _id: "system_ollama", _creationTime: Date.now(), name: "ollama-mcp-server", @@ -120,142 +145,162 @@ const BUILT_IN_MCP_SERVERS: BuiltInMcpServer[] = [ * List all MCP servers for the current user * Includes built-in system servers + user's custom servers */ -export const listMCPServers = query({ +export const listMCPServers = query( { args: {}, - handler: async (ctx) => { + handler: async ( ctx ) => { // Get Convex user document ID - const userId = await getAuthUserId(ctx); + const userId = await getAuthUserId( ctx ); // Always return built-in servers (available to everyone, including anonymous) const builtInServers = [...BUILT_IN_MCP_SERVERS]; // If not authenticated, only return built-in servers - if (!userId) { + if ( !userId ) { return builtInServers; } - // Get user's custom MCP servers - const userServers = await ctx.db - .query("mcpServers") - .withIndex("by_user", (q) => q.eq("userId", userId)) + // Get user's custom MCP servers and tag them as `source: 'user'` so + // callers can reliably discriminate between DB-backed vs built-in servers. + const userServersRaw = await ctx.db + .query( "mcpServers" ) + .withIndex( "by_user", ( q ) => q.eq( "userId", userId ) ) .collect(); + const userServers = userServersRaw.map( ( s ) => ( { + ...s, + source: "user" as const, + } ) ); + // Combine built-in + user servers - return [...builtInServers, ...userServers]; + return [ + ...builtInServers, + ...userServers, + ]; }, -}); +} ); + +/** + * Type guard — narrow MCP server entries to DB-backed servers. + * Use this before calling mutations that require Id<"mcpServers"> (e.g. updateMCPServerStatus). + */ +export function isDbMcpServer( server: MCPServerEntry | null | undefined ): server is DbMcpServer { + return !!server && server.source === "user"; +} /** * Get a specific MCP server by name */ -export const getMCPServerByName = query({ +export const getMCPServerByName = query( { args: { serverName: v.string(), }, - handler: async (ctx, args) => { + handler: async ( ctx, args ) => { // Check if it's a built-in server first - const builtInServer = BUILT_IN_MCP_SERVERS.find(s => s.name === args.serverName); - if (builtInServer) { + const builtInServer = BUILT_IN_MCP_SERVERS.find( s => s.name === args.serverName ); + if ( builtInServer ) { return builtInServer; } // For user-specific MCP servers, require authentication - const userId = await getAuthUserId(ctx); + const userId = await getAuthUserId( ctx ); - if (!userId) { - throw new Error("Not authenticated"); + if ( !userId ) { + throw new Error( "Not authenticated" ); } // Get server by name for this user - const server = await ctx.db - .query("mcpServers") - .withIndex("by_user_and_name", (q) => - q.eq("userId", userId).eq("name", args.serverName) + const serverRaw = await ctx.db + .query( "mcpServers" ) + .withIndex( "by_user_and_name", ( q ) => + q.eq( "userId", userId ).eq( "name", args.serverName ) ) .first(); - return server; + if ( !serverRaw ) return null; + return { ...serverRaw, source: "user" as const }; }, -}); +} ); /** * Get a specific MCP server by name (internal - no auth required) * Used by system actions like queue processor */ -export const getMCPServerByNameInternal = internalQuery({ +export const getMCPServerByNameInternal = internalQuery( { args: { serverName: v.string(), - userId: v.optional(v.id("users")), + userId: v.optional( v.id( "users" ) ), }, - handler: async (ctx, args) => { + handler: async ( ctx, args ) => { // Check if it's a built-in server first - const builtInServer = BUILT_IN_MCP_SERVERS.find(s => s.name === args.serverName); - if (builtInServer) { + const builtInServer = BUILT_IN_MCP_SERVERS.find( s => s.name === args.serverName ); + if ( builtInServer ) { return builtInServer; } - if (args.userId) { + if ( args.userId ) { // Get server by name for specific user const userId = args.userId; // Type narrowing - const server = await ctx.db - .query("mcpServers") - .withIndex("by_user_and_name", (q) => - q.eq("userId", userId).eq("name", args.serverName) + const serverRaw = await ctx.db + .query( "mcpServers" ) + .withIndex( "by_user_and_name", ( q ) => + q.eq( "userId", userId ).eq( "name", args.serverName ) ) .first(); - return server; + if ( !serverRaw ) return null; + return { ...serverRaw, source: "user" } as DbMcpServer; } else { // Get first server by name (for other MCP servers) - const server = await ctx.db - .query("mcpServers") - .filter((q) => q.eq(q.field("name"), args.serverName)) + const serverRaw = await ctx.db + .query( "mcpServers" ) + .filter( ( q ) => q.eq( q.field( "name" ), args.serverName ) ) .first(); - return server; + if ( !serverRaw ) return null; + return { ...serverRaw, source: "user" } as DbMcpServer; } }, -}); +} ); /** * Add a new MCP server */ -export const addMCPServer = mutation({ +export const addMCPServer = mutation( { args: { name: v.string(), command: v.string(), - args: v.array(v.string()), - env: v.optional(v.object({})), - disabled: v.optional(v.boolean()), - timeout: v.optional(v.number()), + args: v.array( v.string() ), + env: v.optional( v.object( {} ) ), + disabled: v.optional( v.boolean() ), + timeout: v.optional( v.number() ), }, - handler: async (ctx, args) => { + handler: async ( ctx, args ) => { // Get Convex user document ID - const userId = await getAuthUserId(ctx); + const userId = await getAuthUserId( ctx ); - if (!userId) { - throw new Error("Not authenticated"); + if ( !userId ) { + throw new Error( "Not authenticated" ); } // Check if server with this name already exists for this user const existingServer = await ctx.db - .query("mcpServers") - .withIndex("by_user_and_name", (q) => - q.eq("userId", userId).eq("name", args.name) + .query( "mcpServers" ) + .withIndex( "by_user_and_name", ( q ) => + q.eq( "userId", userId ).eq( "name", args.name ) ) .first(); - if (existingServer) { - throw new Error(`MCP server with name "${args.name}" already exists`); + if ( existingServer ) { + throw new Error( `MCP server with name "${args.name}" already exists` ); } // Validate server name (alphanumeric, hyphens, underscores only) - if (!/^[a-zA-Z0-9_-]+$/.test(args.name)) { + if ( !/^[a-zA-Z0-9_-]+$/.test( args.name ) ) { throw new Error( "Server name must contain only alphanumeric characters, hyphens, and underscores" ); } // Create new MCP server - const serverId = await ctx.db.insert("mcpServers", { + const serverId = await ctx.db.insert( "mcpServers", { name: args.name, userId: userId, command: args.command, @@ -266,62 +311,62 @@ export const addMCPServer = mutation({ status: "unknown", createdAt: Date.now(), updatedAt: Date.now(), - }); + } ); return serverId; }, -}); +} ); /** * Update an existing MCP server */ -export const updateMCPServer = mutation({ +export const updateMCPServer = mutation( { args: { - serverId: v.id("mcpServers"), - updates: v.object({ - name: v.optional(v.string()), - command: v.optional(v.string()), - args: v.optional(v.array(v.string())), - env: v.optional(v.object({})), - disabled: v.optional(v.boolean()), - timeout: v.optional(v.number()), - }), + serverId: v.id( "mcpServers" ), + updates: v.object( { + name: v.optional( v.string() ), + command: v.optional( v.string() ), + args: v.optional( v.array( v.string() ) ), + env: v.optional( v.object( {} ) ), + disabled: v.optional( v.boolean() ), + timeout: v.optional( v.number() ), + } ), }, - handler: async (ctx, args) => { + handler: async ( ctx, args ) => { // Get Convex user document ID - const userId = await getAuthUserId(ctx); + const userId = await getAuthUserId( ctx ); - if (!userId) { - throw new Error("Not authenticated"); + if ( !userId ) { + throw new Error( "Not authenticated" ); } // Get the server - const server = await ctx.db.get(args.serverId); + const server = await ctx.db.get( args.serverId ); - if (!server) { - throw new Error("MCP server not found"); + if ( !server ) { + throw new Error( "MCP server not found" ); } // Verify ownership - if (server.userId !== userId) { - throw new Error("Not authorized to update this MCP server"); + if ( server.userId !== userId ) { + throw new Error( "Not authorized to update this MCP server" ); } // If updating name, check for conflicts - if (args.updates.name && args.updates.name !== server.name) { + if ( args.updates.name && args.updates.name !== server.name ) { const existingServer = await ctx.db - .query("mcpServers") - .withIndex("by_user_and_name", (q) => - q.eq("userId", userId).eq("name", args.updates.name!) + .query( "mcpServers" ) + .withIndex( "by_user_and_name", ( q ) => + q.eq( "userId", userId ).eq( "name", args.updates.name! ) ) .first(); - if (existingServer) { - throw new Error(`MCP server with name "${args.updates.name}" already exists`); + if ( existingServer ) { + throw new Error( `MCP server with name "${args.updates.name}" already exists` ); } // Validate new server name - if (!/^[a-zA-Z0-9_-]+$/.test(args.updates.name)) { + if ( !/^[a-zA-Z0-9_-]+$/.test( args.updates.name ) ) { throw new Error( "Server name must contain only alphanumeric characters, hyphens, and underscores" ); @@ -329,102 +374,102 @@ export const updateMCPServer = mutation({ } // Update the server - await ctx.db.patch(args.serverId, { + await ctx.db.patch( args.serverId, { ...args.updates, updatedAt: Date.now(), - }); + } ); return args.serverId; }, -}); +} ); /** * Delete an MCP server */ -export const deleteMCPServer = mutation({ +export const deleteMCPServer = mutation( { args: { - serverId: v.id("mcpServers"), + serverId: v.id( "mcpServers" ), }, - handler: async (ctx, args) => { + handler: async ( ctx, args ) => { // Get Convex user document ID - const userId = await getAuthUserId(ctx); + const userId = await getAuthUserId( ctx ); - if (!userId) { - throw new Error("Not authenticated"); + if ( !userId ) { + throw new Error( "Not authenticated" ); } // Get the server - const server = await ctx.db.get(args.serverId); + const server = await ctx.db.get( args.serverId ); - if (!server) { - throw new Error("MCP server not found"); + if ( !server ) { + throw new Error( "MCP server not found" ); } // Verify ownership - if (server.userId !== userId) { - throw new Error("Not authorized to delete this MCP server"); + if ( server.userId !== userId ) { + throw new Error( "Not authorized to delete this MCP server" ); } // Delete the server - await ctx.db.delete(args.serverId); + await ctx.db.delete( args.serverId ); return { success: true }; }, -}); +} ); /** * Update MCP server status (internal use) */ -export const updateMCPServerStatus = mutation({ +export const updateMCPServerStatus = mutation( { args: { - serverId: v.id("mcpServers"), + serverId: v.id( "mcpServers" ), status: v.string(), - lastConnected: v.optional(v.number()), - lastError: v.optional(v.string()), - availableTools: v.optional(v.array(v.object({ + lastConnected: v.optional( v.number() ), + lastError: v.optional( v.string() ), + availableTools: v.optional( v.array( v.object( { name: v.string(), - description: v.optional(v.string()), - inputSchema: v.optional(v.any()), - }))), + description: v.optional( v.string() ), + inputSchema: v.optional( v.any() ), + } ) ) ), }, - handler: async (ctx, args) => { - const server = await ctx.db.get(args.serverId); + handler: async ( ctx, args ) => { + const server = await ctx.db.get( args.serverId ); - if (!server) { - throw new Error("MCP server not found"); + if ( !server ) { + throw new Error( "MCP server not found" ); } - await ctx.db.patch(args.serverId, { + await ctx.db.patch( args.serverId, { status: args.status, lastConnected: args.lastConnected, lastError: args.lastError, availableTools: args.availableTools, updatedAt: Date.now(), - }); + } ); return { success: true }; }, -}); +} ); /** * Test MCP server connection */ -export const testMCPConnection = action({ +export const testMCPConnection = action( { args: { - serverId: v.id("mcpServers"), + serverId: v.id( "mcpServers" ), }, - handler: async (ctx, args): Promise<{ + handler: async ( ctx, args ): Promise<{ success: boolean; status: string; tools?: any[]; error?: string; }> => { // Get the server - const server: any = await ctx.runQuery(api.mcpConfig.getMCPServerById, { + const server: any = await ctx.runQuery( api.mcpConfig.getMCPServerById, { serverId: args.serverId, - }); + } ); - if (!server) { + if ( !server ) { return { success: false, status: "error", @@ -433,53 +478,53 @@ export const testMCPConnection = action({ } // Use the MCP client to test connection - const result: any = await ctx.runAction(api.mcpClient.testMCPServerConnection, { + const result: any = await ctx.runAction( api.mcpClient.testMCPServerConnection, { serverName: server.name, - }); + } ); // Update server status based on test result - await ctx.runMutation(api.mcpConfig.updateMCPServerStatus, { + await ctx.runMutation( api.mcpConfig.updateMCPServerStatus, { serverId: args.serverId, status: result.status, lastConnected: result.success ? Date.now() : undefined, lastError: result.error, - availableTools: result.tools?.map((tool: any) => ({ + availableTools: result.tools?.map( ( tool: any ) => ( { name: tool.name, description: tool.description, inputSchema: tool.inputSchema, - })), - }); + } ) ), + } ); return result; }, -}); +} ); /** * Get MCP server by ID (internal query) */ -export const getMCPServerById = query({ +export const getMCPServerById = query( { args: { - serverId: v.id("mcpServers"), + serverId: v.id( "mcpServers" ), }, - handler: async (ctx, args) => { + handler: async ( ctx, args ) => { // Get Convex user document ID - const userId = await getAuthUserId(ctx); + const userId = await getAuthUserId( ctx ); - if (!userId) { - throw new Error("Not authenticated"); + if ( !userId ) { + throw new Error( "Not authenticated" ); } - const server = await ctx.db.get(args.serverId); + const server = await ctx.db.get( args.serverId ); - if (!server) { + if ( !server ) { return null; } // Verify ownership - if (server.userId !== userId) { - throw new Error("Not authorized to access this MCP server"); + if ( server.userId !== userId ) { + throw new Error( "Not authorized to access this MCP server" ); } return server; }, -}); +} ); diff --git a/src/components/MCPManagementPanel.tsx b/src/components/MCPManagementPanel.tsx index 094e54c..85eaae9 100644 --- a/src/components/MCPManagementPanel.tsx +++ b/src/components/MCPManagementPanel.tsx @@ -18,7 +18,8 @@ import { MCPServerForm } from './MCPServerForm'; import { MCPToolTester } from './MCPToolTester'; interface MCPServer { - _id: Id<"mcpServers">; + _id: Id<"mcpServers"> | string; + source?: "system" | "user"; name: string; command: string; args: string[]; @@ -198,32 +199,38 @@ export function MCPManagementPanel() { - {/* Action Buttons */} + {/* Action Buttons: only allow Test/Edit/Delete for DB-backed (user) servers */}
- - - + {server.source === 'user' ? ( + <> + + + + + ) : ( +
System
+ )}
diff --git a/src/components/MCPServerForm.tsx b/src/components/MCPServerForm.tsx index 7daf1ac..805cd63 100644 --- a/src/components/MCPServerForm.tsx +++ b/src/components/MCPServerForm.tsx @@ -7,7 +7,8 @@ import { toast } from 'sonner'; interface MCPServerFormProps { server?: { - _id: Id<"mcpServers">; + _id: Id<"mcpServers"> | string; + source?: "system" | "user"; name: string; command: string; args: string[]; diff --git a/src/components/MCPServerSelector.tsx b/src/components/MCPServerSelector.tsx index 7e6c85a..1a69abc 100644 --- a/src/components/MCPServerSelector.tsx +++ b/src/components/MCPServerSelector.tsx @@ -10,7 +10,10 @@ import { Id } from "../../convex/_generated/dataModel"; import { Server, CheckCircle, Circle, Info, Wrench } from "lucide-react"; interface MCPServer { - _id: Id<"mcpServers">; + // Built-in servers use string synthetic ids (e.g. "system_ollama"); + // DB-backed servers use Convex Id<"mcpServers">. Accept both. + _id: Id<"mcpServers"> | string; + source?: "system" | "user"; name: string; command: string; args: string[]; diff --git a/src/components/MCPToolTester.tsx b/src/components/MCPToolTester.tsx index 04346d5..730d03b 100644 --- a/src/components/MCPToolTester.tsx +++ b/src/components/MCPToolTester.tsx @@ -2,11 +2,11 @@ import React, { useState } from 'react'; import { useAction } from 'convex/react'; import { api } from '../../convex/_generated/api'; import { Id } from '../../convex/_generated/dataModel'; -import { - X, - Play, - CheckCircle, - XCircle, +import { + X, + Play, + CheckCircle, + XCircle, Save, Copy, ChevronDown, @@ -16,7 +16,8 @@ import { toast } from 'sonner'; interface MCPToolTesterProps { server: { - _id: Id<"mcpServers">; + _id: Id<"mcpServers"> | string; + source?: "system" | "user"; name: string; availableTools?: Array<{ name: string; @@ -60,7 +61,7 @@ export function MCPToolTester({ server, onClose }: MCPToolTesterProps) { const handleToolSelect = (toolName: string) => { setSelectedTool(toolName); setTestResult(null); - + // Load saved template if exists if (savedTemplates.has(toolName)) { setParameters(savedTemplates.get(toolName)!); @@ -81,7 +82,7 @@ export function MCPToolTester({ server, onClose }: MCPToolTesterProps) { } const defaults: Record = {}; - + Object.entries(schema.properties).forEach(([key, prop]: [string, any]) => { if (prop.type === 'string') { defaults[key] = prop.default || '';