From 8976caa9669158564bc74ae898b81e9de6ca421b Mon Sep 17 00:00:00 2001 From: root Date: Sun, 15 Mar 2026 09:56:04 +0000 Subject: [PATCH] feat(mcp-server): add org review follow-up hooks --- .../mcp-server/src/lib/platform-client.ts | 23 ++ .../a2a/governance-integration.test.ts | 28 ++ .../mcp-server/src/modules/a2a/governance.ts | 43 ++- .../modules/a2a/org-provisioning-pipeline.ts | 273 +++++++++++++++--- 4 files changed, 319 insertions(+), 48 deletions(-) diff --git a/services/mcp-server/src/lib/platform-client.ts b/services/mcp-server/src/lib/platform-client.ts index 9246a993..189b77d2 100644 --- a/services/mcp-server/src/lib/platform-client.ts +++ b/services/mcp-server/src/lib/platform-client.ts @@ -337,6 +337,29 @@ export async function diagnosticsListSessions( ); } +// ── Reviews ────────────────────────────────────────────────────────────────── + +export async function reviewsCreate( + body: { + title: string; + description: string; + category: string; + priority?: 'low' | 'normal' | 'high' | 'urgent'; + scope?: 'org' | 'workspace'; + orgId: string; + workspaceId?: string; + assignedTo?: string; + runId?: string; + source: string; + actionType: string; + metadata?: Record; + dueAt?: string; + }, + opts: PlatformClientOptions +): Promise { + return platformFetch('/api/reviews', { method: 'POST', body: JSON.stringify(body) }, opts); +} + export async function diagnosticsCreateSession( body: { productId: string; diff --git a/services/mcp-server/src/modules/a2a/governance-integration.test.ts b/services/mcp-server/src/modules/a2a/governance-integration.test.ts index d89bcea8..f463bfce 100644 --- a/services/mcp-server/src/modules/a2a/governance-integration.test.ts +++ b/services/mcp-server/src/modules/a2a/governance-integration.test.ts @@ -7,6 +7,7 @@ process.env.EXTRACTION_SERVICE_URL ??= 'http://localhost:4005'; vi.mock('../../lib/platform-client.js', () => ({ aiBudgetsRecordSpend: vi.fn(), + reviewsCreate: vi.fn(), supportCasesCreate: vi.fn(), runsCreate: vi.fn(), runsUpdate: vi.fn(), @@ -24,12 +25,16 @@ vi.mock('../../lib/jarvis-client.js', () => ({ })); vi.mock('../../lib/lysnrai-client.js', () => ({ + lysnraiOrgGet: vi.fn(), + lysnraiApiTokensList: vi.fn(), + lysnraiSessionsList: vi.fn(), lysnraiTranscriptsList: vi.fn(), lysnraiTranscriptRunExtraction: vi.fn(), })); import { aiBudgetsRecordSpend, + reviewsCreate, supportCasesCreate, runsCreate, runsUpdate, @@ -39,6 +44,9 @@ import { import { extractionRun } from '../../lib/extraction-client.js'; import { jarvisMarketplaceCertify, jarvisMarketplaceGetListing } from '../../lib/jarvis-client.js'; import { + lysnraiApiTokensList, + lysnraiOrgGet, + lysnraiSessionsList, lysnraiTranscriptRunExtraction, lysnraiTranscriptsList, } from '../../lib/lysnrai-client.js'; @@ -62,6 +70,7 @@ function buildReq(id = 'req_1'): McpToolRequest { beforeEach(() => { vi.clearAllMocks(); vi.mocked(aiBudgetsRecordSpend).mockResolvedValue({} as never); + vi.mocked(reviewsCreate).mockResolvedValue({} as never); vi.mocked(supportCasesCreate).mockResolvedValue({} as never); vi.mocked(runsCreate).mockResolvedValue({} as never); vi.mocked(runsUpdate).mockResolvedValue({} as never); @@ -150,4 +159,23 @@ describe('A2A governance integration', () => { expect.objectContaining({ productId: 'lysnrai' }) ); }); + + it('creates an org-scoped review when org provisioning needs follow-up', async () => { + const { runOrgProvisioningPipeline } = await import('./org-provisioning-pipeline.js'); + vi.mocked(lysnraiOrgGet).mockResolvedValue({ id: 'org_1', name: 'Acme' } as never); + vi.mocked(lysnraiApiTokensList).mockResolvedValue({ tokens: [] } as never); + vi.mocked(lysnraiSessionsList).mockResolvedValue({ total: 0 } as never); + + const report = await runOrgProvisioningPipeline('org_1', buildReq('req_org')); + + expect(report.onboardingStatus).toBe('new_org'); + expect(reviewsCreate).toHaveBeenCalledWith( + expect.objectContaining({ + orgId: 'org_1', + category: 'org_onboarding', + actionType: 'org_onboarding_followup', + }), + expect.objectContaining({ productId: 'lysnrai' }) + ); + }); }); diff --git a/services/mcp-server/src/modules/a2a/governance.ts b/services/mcp-server/src/modules/a2a/governance.ts index 62b9a254..87ea4701 100644 --- a/services/mcp-server/src/modules/a2a/governance.ts +++ b/services/mcp-server/src/modules/a2a/governance.ts @@ -1,4 +1,8 @@ -import { aiBudgetsRecordSpend, supportCasesCreate } from '../../lib/platform-client.js'; +import { + aiBudgetsRecordSpend, + reviewsCreate, + supportCasesCreate, +} from '../../lib/platform-client.js'; import type { PlatformClientOptions } from '../../lib/platform-client.js'; interface BudgetSpendInput { @@ -26,6 +30,19 @@ interface SupportCaseInput { tags?: string[]; } +interface ReviewInput { + productId: string; + orgId: string; + workspaceId?: string; + runId: string; + title: string; + description: string; + category: string; + priority?: 'low' | 'normal' | 'high' | 'urgent'; + actionType: string; + metadata?: Record; +} + export async function recordBudgetSpend( input: BudgetSpendInput, opts: PlatformClientOptions @@ -70,6 +87,30 @@ export async function createSupportCaseForRun( ); } +export async function createReviewForRun( + input: ReviewInput, + opts: PlatformClientOptions +): Promise { + await safeGovernanceCall(() => + reviewsCreate( + { + title: input.title, + description: input.description, + category: input.category, + priority: input.priority ?? 'high', + scope: input.workspaceId ? 'workspace' : 'org', + orgId: input.orgId, + workspaceId: input.workspaceId, + runId: input.runId, + source: 'mcp.a2a', + actionType: input.actionType, + metadata: input.metadata, + }, + withProduct(opts, input.productId) + ) + ); +} + function withProduct(opts: PlatformClientOptions, productId: string): PlatformClientOptions { return { token: opts.token, diff --git a/services/mcp-server/src/modules/a2a/org-provisioning-pipeline.ts b/services/mcp-server/src/modules/a2a/org-provisioning-pipeline.ts index b2ca6a0d..98635fec 100644 --- a/services/mcp-server/src/modules/a2a/org-provisioning-pipeline.ts +++ b/services/mcp-server/src/modules/a2a/org-provisioning-pipeline.ts @@ -21,6 +21,15 @@ import { type OrgDoc, type ApiTokenDoc, } from '../../lib/lysnrai-client.js'; +import { + trackRunCompleted, + trackRunFailed, + trackRunStarted, + trackStepCompleted, + trackStepFailed, + trackStepStarted, +} from './run-tracker.js'; +import { createReviewForRun } from './governance.js'; // ── Types ────────────────────────────────────────────────────────────────────── @@ -117,65 +126,235 @@ function buildProvisioningActions(inspection: OrgInspection): { // ── Pipeline runner ──────────────────────────────────────────────────────────── -async function runOrgProvisioningPipeline( +export async function runOrgProvisioningPipeline( orgId: string, req: McpToolRequest ): Promise { const runId = randomUUID(); const opts = { token: req.headers.authorization?.slice(7), requestId: req.id }; + let currentStep: { stepName: 'inspect' | 'provision' | 'report'; order: number } | undefined; - req.log.info({ runId, stepId: 'inspect', orgId }, 'OrgInspectorAgent start'); - const inspection = await inspectOrg(orgId, opts); - req.log.info({ runId, stepId: 'inspect', found: inspection !== null }, 'OrgInspectorAgent done'); - - if (!inspection) { - return { + await safeTrack(() => + trackRunStarted({ runId, productId: 'lysnrai', - orgId, - orgName: 'unknown', - existingTokens: 0, - defaultTokenNeeded: false, - defaultTokenNote: 'Organization not found or access denied.', - sessionCount: 0, - onboardingStatus: 'needs_attention', - recommendations: ['Verify the orgId is correct and the caller has admin access.'], - summary: `Failed to inspect org ${orgId}. Organization not found or inaccessible.`, - generatedAt: new Date().toISOString(), - }; - } - - req.log.info({ runId, stepId: 'provision' }, 'ProvisioningActionAgent start'); - const { defaultTokenNote, recommendations } = buildProvisioningActions(inspection); - req.log.info( - { runId, stepId: 'provision', recommendationCount: recommendations.length }, - 'ProvisioningActionAgent done' + name: 'org-provisioning', + requestId: req.id, + token: opts.token, + input: { orgId }, + }) ); - req.log.info({ runId, stepId: 'report' }, 'OrgReportAgent start'); - const onboardingStatus = inspection.needsOnboarding - ? 'new_org' - : inspection.sessionCount > 0 && inspection.existingTokenCount > 0 - ? 'complete' - : 'needs_attention'; + try { + currentStep = { stepName: 'inspect', order: 1 }; + const inspectStep = currentStep; + await safeTrack(() => + trackStepStarted({ + runId, + productId: 'lysnrai', + stepName: inspectStep.stepName, + order: inspectStep.order, + token: opts.token, + requestId: req.id, + input: { orgId }, + }) + ); + req.log.info({ runId, stepId: 'inspect', orgId }, 'OrgInspectorAgent start'); + const inspection = await inspectOrg(orgId, opts); + await safeTrack(() => + trackStepCompleted({ + runId, + productId: 'lysnrai', + stepName: inspectStep.stepName, + order: inspectStep.order, + token: opts.token, + requestId: req.id, + output: { found: inspection !== null }, + }) + ); + req.log.info( + { runId, stepId: 'inspect', found: inspection !== null }, + 'OrgInspectorAgent done' + ); - const summary = `Org "${inspection.org.name}" (${orgId}): ${inspection.existingTokenCount} API token(s), ${inspection.sessionCount} session(s). Status: ${onboardingStatus}. ${recommendations.length} action(s) recommended.`; - req.log.info({ runId, stepId: 'report', summary }, 'OrgReportAgent done'); + if (!inspection) { + const result = { + runId, + productId: 'lysnrai' as const, + orgId, + orgName: 'unknown', + existingTokens: 0, + defaultTokenNeeded: false, + defaultTokenNote: 'Organization not found or access denied.', + sessionCount: 0, + onboardingStatus: 'needs_attention' as const, + recommendations: ['Verify the orgId is correct and the caller has admin access.'], + summary: `Failed to inspect org ${orgId}. Organization not found or inaccessible.`, + generatedAt: new Date().toISOString(), + }; + await safeTrack(() => + trackRunCompleted({ + runId, + productId: 'lysnrai', + name: 'org-provisioning', + requestId: req.id, + token: opts.token, + output: { onboardingStatus: result.onboardingStatus }, + }) + ); + return result; + } - return { - runId, - productId: 'lysnrai', - orgId, - orgName: inspection.org.name, - existingTokens: inspection.existingTokenCount, - defaultTokenNeeded: inspection.needsDefaultToken, - defaultTokenNote, - sessionCount: inspection.sessionCount, - onboardingStatus, - recommendations, - summary, - generatedAt: new Date().toISOString(), - }; + currentStep = { stepName: 'provision', order: 2 }; + const provisionStep = currentStep; + await safeTrack(() => + trackStepStarted({ + runId, + productId: 'lysnrai', + stepName: provisionStep.stepName, + order: provisionStep.order, + token: opts.token, + requestId: req.id, + }) + ); + req.log.info({ runId, stepId: 'provision' }, 'ProvisioningActionAgent start'); + const { defaultTokenNote, recommendations } = buildProvisioningActions(inspection); + await safeTrack(() => + trackStepCompleted({ + runId, + productId: 'lysnrai', + stepName: provisionStep.stepName, + order: provisionStep.order, + token: opts.token, + requestId: req.id, + output: { recommendationCount: recommendations.length }, + }) + ); + req.log.info( + { runId, stepId: 'provision', recommendationCount: recommendations.length }, + 'ProvisioningActionAgent done' + ); + + currentStep = { stepName: 'report', order: 3 }; + const reportStep = currentStep; + await safeTrack(() => + trackStepStarted({ + runId, + productId: 'lysnrai', + stepName: reportStep.stepName, + order: reportStep.order, + token: opts.token, + requestId: req.id, + }) + ); + req.log.info({ runId, stepId: 'report' }, 'OrgReportAgent start'); + const onboardingStatus: OrgProvisioningReport['onboardingStatus'] = inspection.needsOnboarding + ? 'new_org' + : inspection.sessionCount > 0 && inspection.existingTokenCount > 0 + ? 'complete' + : 'needs_attention'; + + const summary = `Org "${inspection.org.name}" (${orgId}): ${inspection.existingTokenCount} API token(s), ${inspection.sessionCount} session(s). Status: ${onboardingStatus}. ${recommendations.length} action(s) recommended.`; + const report = { + runId, + productId: 'lysnrai' as const, + orgId, + orgName: inspection.org.name, + existingTokens: inspection.existingTokenCount, + defaultTokenNeeded: inspection.needsDefaultToken, + defaultTokenNote, + sessionCount: inspection.sessionCount, + onboardingStatus, + recommendations, + summary, + generatedAt: new Date().toISOString(), + }; + await safeTrack(() => + trackStepCompleted({ + runId, + productId: 'lysnrai', + stepName: reportStep.stepName, + order: reportStep.order, + token: opts.token, + requestId: req.id, + output: { + onboardingStatus: report.onboardingStatus, + recommendationCount: recommendations.length, + }, + }) + ); + await safeTrack(() => + trackRunCompleted({ + runId, + productId: 'lysnrai', + name: 'org-provisioning', + requestId: req.id, + token: opts.token, + output: { + onboardingStatus: report.onboardingStatus, + recommendationCount: recommendations.length, + }, + }) + ); + if (report.onboardingStatus !== 'complete') { + await createReviewForRun( + { + productId: 'lysnrai', + orgId, + runId, + title: `Org provisioning follow-up for ${inspection.org.name}`, + description: summary, + category: 'org_onboarding', + priority: report.onboardingStatus === 'new_org' ? 'urgent' : 'high', + actionType: 'org_onboarding_followup', + metadata: { + recommendationCount: recommendations.length, + defaultTokenNeeded: inspection.needsDefaultToken, + sessionCount: inspection.sessionCount, + }, + }, + opts + ); + } + req.log.info({ runId, stepId: 'report', summary }, 'OrgReportAgent done'); + + return report; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (currentStep) { + const failedStep = currentStep; + await safeTrack(() => + trackStepFailed({ + runId, + productId: 'lysnrai', + stepName: failedStep.stepName, + order: failedStep.order, + token: opts.token, + requestId: req.id, + error: message, + }) + ); + } + await safeTrack(() => + trackRunFailed({ + runId, + productId: 'lysnrai', + name: 'org-provisioning', + requestId: req.id, + token: opts.token, + error: message, + }) + ); + throw error; + } +} + +async function safeTrack(fn: () => Promise): Promise { + try { + await fn(); + } catch { + // Tracking must never fail the pipeline itself. + } } // ── MCP tool registration ─────────────────────────────────────────────────────