From 90550bf4da291d998a333cbf5e59f577e8acb7a6 Mon Sep 17 00:00:00 2001 From: ivkond Date: Sun, 19 Apr 2026 13:37:30 +0300 Subject: [PATCH] feat(mcp): Phase 6 group resources + automated #794 smoke - Register `gitnexus://group/{name}/contracts` and `gitnexus://group/{name}/status` MCP resources backed by GroupService (resources.ts + LocalBackend resource handlers). - Cover the new resources with unit tests in test/unit/resources.test.ts. - Convert the Phase 5.4 manual smoke into a table-driven test in test/unit/mcp/group-repo-routing.test.ts: `impact` / `query` / `context` with `repo: "@myproduct"` + `service: "app/backend"` route to the matching GroupService method and the leading `@` is stripped before delegation. - Ignore local agent scratch dirs (.tmp/, .agents/) so review prompts and generated skill mirrors never leak into a commit. Refs #794 Made-with: Cursor --- .gitignore | 6 +- gitnexus/src/mcp/local/local-backend.ts | 41 +++++ gitnexus/src/mcp/resources.ts | 141 +++++++++++++++--- .../test/unit/mcp/group-repo-routing.test.ts | 42 ++++++ gitnexus/test/unit/resources.test.ts | 86 ++++++++++- 5 files changed, 293 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index b769da0dc..88419e481 100644 --- a/.gitignore +++ b/.gitignore @@ -100,4 +100,8 @@ gitnexus/vendor/**/node_modules/ .swarm/ -local_docs/ \ No newline at end of file +local_docs/ + +# Local agent scratch / review prompts (never commit) +.tmp/ +.agents/ \ No newline at end of file diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index eb1970c6c..edf5eb5b0 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -2658,6 +2658,47 @@ export class LocalBackend { return this.getGroupService().groupSync(params); } + /** + * MCP resource body for `gitnexus://group/{name}/contracts` (Issue #794). + */ + async readGroupContractsResource( + groupName: string, + filter: { type?: string; repo?: string; unmatchedOnly?: boolean }, + ): Promise { + try { + const params: Record = { name: groupName }; + if (filter.type !== undefined) params.type = filter.type; + if (filter.repo !== undefined) params.repo = filter.repo; + if (filter.unmatchedOnly === true) params.unmatchedOnly = true; + const raw = await this.getGroupService().groupContracts(params); + return LocalBackend.formatGroupResourcePayload(raw); + } catch (e) { + return `error: ${e instanceof Error ? e.message : String(e)}`; + } + } + + /** + * MCP resource body for `gitnexus://group/{name}/status` (Issue #794). + */ + async readGroupStatusResource(groupName: string): Promise { + try { + const raw = await this.getGroupService().groupStatus({ name: groupName }); + return LocalBackend.formatGroupResourcePayload(raw); + } catch (e) { + return `error: ${e instanceof Error ? e.message : String(e)}`; + } + } + + private static formatGroupResourcePayload(raw: unknown): string { + if (raw && typeof raw === 'object' && 'error' in raw) { + const err = (raw as { error?: unknown }).error; + if (typeof err === 'string' && err.length > 0) { + return `error: ${err}`; + } + } + return JSON.stringify(raw, null, 2); + } + /** * Fetch Route nodes with their consumers in a single query. * Shared by routeMap and shapeCheck to avoid N+1 query patterns. diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index dce81bab4..6fbab99ce 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -84,38 +84,134 @@ export function getResourceTemplates(): ResourceTemplate[] { description: 'Step-by-step execution trace', mimeType: 'text/yaml', }, + { + uriTemplate: 'gitnexus://group/{name}/contracts', + name: 'Group Contract Registry', + description: + 'Cross-repo contract registry for a repository group. Optional query: type, repo, unmatchedOnly (true|false).', + mimeType: 'text/yaml', + }, + { + uriTemplate: 'gitnexus://group/{name}/status', + name: 'Group Index Status', + description: 'Per-repo index and contract-registry staleness for a repository group', + mimeType: 'text/yaml', + }, ]; } -/** - * Parse a resource URI to extract the repo name and resource type. - */ -function parseUri(uri: string): { repoName?: string; resourceType: string; param?: string } { - if (uri === 'gitnexus://repos') return { resourceType: 'repos' }; - if (uri === 'gitnexus://setup') return { resourceType: 'setup' }; +/** Query parameters for `gitnexus://group/{name}/contracts` */ +export type GroupContractsResourceFilter = { + type?: string; + repo?: string; + unmatchedOnly?: boolean; +}; - // Repo-scoped: gitnexus://repo/{name}/context - const repoMatch = uri.match(/^gitnexus:\/\/repo\/([^/]+)\/(.+)$/); - if (repoMatch) { - const repoName = decodeURIComponent(repoMatch[1]); - const rest = repoMatch[2]; +/** Normalized parse result for GitNexus MCP resource URIs */ +export type ParsedGitnexusResource = + | { kind: 'repos' } + | { kind: 'setup' } + | { + kind: 'repo'; + repoName: string; + resourceType: string; + param?: string; + } + | { + kind: 'group'; + groupName: string; + resourceType: 'contracts'; + contractsFilter: GroupContractsResourceFilter; + } + | { kind: 'group'; groupName: string; resourceType: 'status' }; + +function parseUnmatchedOnlyParam(raw: string | null): boolean | undefined { + if (raw === null) return undefined; + const v = raw.trim().toLowerCase(); + if (v === 'true' || v === '1') return true; + if (v === 'false' || v === '0') return false; + return undefined; +} + +/** + * Parse a GitNexus resource URI (repos, setup, per-repo, or per-group templates). + * Used by `readResource` and tests (round-trip / dispatch coverage). + */ +export function parseResourceUri(uri: string): ParsedGitnexusResource { + if (uri === 'gitnexus://repos') return { kind: 'repos' }; + if (uri === 'gitnexus://setup') return { kind: 'setup' }; + + let u: URL; + try { + u = new URL(uri); + } catch { + throw new Error(`Unknown resource URI: ${uri}`); + } + + if (u.protocol !== 'gitnexus:') { + throw new Error(`Unknown resource URI: ${uri}`); + } + + if (u.hostname === 'group') { + const segments = u.pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean); + if (segments.length < 2) { + throw new Error( + `Invalid group resource URI (expected gitnexus://group/{name}/contracts or .../status): ${uri}`, + ); + } + const tail = segments[segments.length - 1]!; + if (tail !== 'contracts' && tail !== 'status') { + throw new Error(`Unknown group resource path in URI: ${uri}`); + } + const groupName = segments + .slice(0, -1) + .map((s) => decodeURIComponent(s)) + .join('/'); + if (!groupName) { + throw new Error(`Invalid group resource URI (empty group name): ${uri}`); + } + if (tail === 'status') { + return { kind: 'group', groupName, resourceType: 'status' }; + } + const contractsFilter: GroupContractsResourceFilter = {}; + const type = u.searchParams.get('type'); + if (type && type.trim()) contractsFilter.type = type.trim(); + const repo = u.searchParams.get('repo'); + if (repo && repo.trim()) contractsFilter.repo = repo.trim(); + if (u.searchParams.has('unmatchedOnly')) { + const coerced = parseUnmatchedOnlyParam(u.searchParams.get('unmatchedOnly')); + if (coerced !== undefined) contractsFilter.unmatchedOnly = coerced; + } + return { kind: 'group', groupName, resourceType: 'contracts', contractsFilter }; + } + + if (u.hostname === 'repo') { + const segments = u.pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean); + if (segments.length < 2) { + throw new Error(`Unknown resource URI: ${uri}`); + } + const repoName = decodeURIComponent(segments[0]!); + const restEncoded = segments.slice(1); + const rest = restEncoded.map((s) => decodeURIComponent(s)).join('/'); if (rest.startsWith('cluster/')) { return { + kind: 'repo', repoName, resourceType: 'cluster', - param: decodeURIComponent(rest.replace('cluster/', '')), + param: rest.replace(/^cluster\//, ''), }; } if (rest.startsWith('process/')) { return { + kind: 'repo', repoName, resourceType: 'process', - param: decodeURIComponent(rest.replace('process/', '')), + param: rest.replace(/^process\//, ''), }; } - return { repoName, resourceType: rest }; + return { kind: 'repo', repoName, resourceType: rest }; } throw new Error(`Unknown resource URI: ${uri}`); @@ -125,18 +221,23 @@ function parseUri(uri: string): { repoName?: string; resourceType: string; param * Read a resource and return its content */ export async function readResource(uri: string, backend: LocalBackend): Promise { - const parsed = parseUri(uri); + const parsed = parseResourceUri(uri); - // Global repos list — no repo context needed - if (parsed.resourceType === 'repos') { + if (parsed.kind === 'repos') { return getReposResource(backend); } - // Setup resource — returns AGENTS.md content for all repos - if (parsed.resourceType === 'setup') { + if (parsed.kind === 'setup') { return getSetupResource(backend); } + if (parsed.kind === 'group') { + if (parsed.resourceType === 'contracts') { + return backend.readGroupContractsResource(parsed.groupName, parsed.contractsFilter); + } + return backend.readGroupStatusResource(parsed.groupName); + } + const repoName = parsed.repoName; switch (parsed.resourceType) { @@ -241,6 +342,8 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro lines.push(` - gitnexus://repo/${context.projectName}/processes: All execution flows`); lines.push(` - gitnexus://repo/${context.projectName}/cluster/{name}: Module details`); lines.push(` - gitnexus://repo/${context.projectName}/process/{name}: Process trace`); + lines.push(' - gitnexus://group/{name}/contracts: Group contract registry (optional ?type=&repo=&unmatchedOnly=)'); + lines.push(' - gitnexus://group/{name}/status: Group index / contract staleness'); return lines.join('\n'); } diff --git a/gitnexus/test/unit/mcp/group-repo-routing.test.ts b/gitnexus/test/unit/mcp/group-repo-routing.test.ts index 998a10344..69ca0d085 100644 --- a/gitnexus/test/unit/mcp/group-repo-routing.test.ts +++ b/gitnexus/test/unit/mcp/group-repo-routing.test.ts @@ -172,4 +172,46 @@ repos: const backend = new LocalBackend(); await expect(backend.callTool('group_status', { name: 'g1' })).rejects.toThrow(/Removed tools/); }); + + describe('Issue #794 manual smoke checklist (automated)', () => { + beforeEach(() => { + const groupDir = path.join(tmpDir, 'groups', 'myproduct'); + fs.mkdirSync(groupDir, { recursive: true }); + fs.writeFileSync( + path.join(groupDir, 'group.yaml'), + `version: 1 +name: myproduct +repos: + app/backend: test-backend + app/frontend: test-frontend +`, + ); + }); + + it.each([ + { + method: 'impact', + params: { repo: '@myproduct', target: 'UserService.login', service: 'app/backend' }, + spy: () => groupSpyImpact, + }, + { + method: 'query', + params: { repo: '@myproduct', query: 'login', service: 'app/backend' }, + spy: () => groupSpyQuery, + }, + { + method: 'context', + params: { repo: '@myproduct', target: 'UserService.login', service: 'app/backend' }, + spy: () => groupSpyContext, + }, + ])('$method with repo "@myproduct" routes to GroupService and forwards service', async ({ method, params, spy }) => { + const backend = new LocalBackend(); + await backend.callTool(method, params); + expect(spy()).toHaveBeenCalledWith( + expect.objectContaining({ name: 'myproduct', service: 'app/backend' }), + ); + const callArg = spy().mock.calls[0][0] as Record; + expect(typeof callArg.repo === 'string' ? (callArg.repo as string).startsWith('@') : false).toBe(false); + }); + }); }); diff --git a/gitnexus/test/unit/resources.test.ts b/gitnexus/test/unit/resources.test.ts index 6b203c969..ccd8461f1 100644 --- a/gitnexus/test/unit/resources.test.ts +++ b/gitnexus/test/unit/resources.test.ts @@ -12,6 +12,7 @@ import { describe, it, expect, vi } from 'vitest'; import { getResourceDefinitions, getResourceTemplates, + parseResourceUri, readResource, } from '../../src/mcp/resources.js'; @@ -36,6 +37,12 @@ function createMockBackend(overrides: Partial> = {}): any { queryProcessDetail: vi .fn() .mockResolvedValue(overrides.processDetail ?? { error: 'Not found' }), + readGroupContractsResource: vi + .fn() + .mockResolvedValue(overrides.groupContractsBody ?? 'contracts: []\n'), + readGroupStatusResource: vi + .fn() + .mockResolvedValue(overrides.groupStatusBody ?? 'group: mock\n'), ...overrides, }; } @@ -73,12 +80,12 @@ describe('getResourceDefinitions', () => { }); describe('getResourceTemplates', () => { - it('returns 6 dynamic templates', () => { + it('returns 8 dynamic templates', () => { const templates = getResourceTemplates(); - expect(templates).toHaveLength(6); + expect(templates).toHaveLength(8); }); - it('includes context, clusters, processes, schema, cluster detail, process detail', () => { + it('includes context, clusters, processes, schema, cluster detail, process detail, group contracts/status', () => { const templates = getResourceTemplates(); const uris = templates.map((t) => t.uriTemplate); expect(uris).toContain('gitnexus://repo/{name}/context'); @@ -87,6 +94,8 @@ describe('getResourceTemplates', () => { expect(uris).toContain('gitnexus://repo/{name}/schema'); expect(uris).toContain('gitnexus://repo/{name}/cluster/{clusterName}'); expect(uris).toContain('gitnexus://repo/{name}/process/{processName}'); + expect(uris).toContain('gitnexus://group/{name}/contracts'); + expect(uris).toContain('gitnexus://group/{name}/status'); }); it('each template has uriTemplate, name, description, mimeType', () => { @@ -99,6 +108,61 @@ describe('getResourceTemplates', () => { }); }); +describe('parseResourceUri', () => { + it('parses group contracts without query', () => { + const p = parseResourceUri('gitnexus://group/acme/contracts'); + expect(p).toEqual({ + kind: 'group', + groupName: 'acme', + resourceType: 'contracts', + contractsFilter: {}, + }); + }); + + it('parses nested group name and contracts query params', () => { + const p = parseResourceUri( + 'gitnexus://group/acme/billing/contracts?type=http&repo=app%2Fapi&unmatchedOnly=true', + ); + expect(p.kind).toBe('group'); + if (p.kind !== 'group' || p.resourceType !== 'contracts') throw new Error('unexpected'); + expect(p.groupName).toBe('acme/billing'); + expect(p.contractsFilter).toEqual({ + type: 'http', + repo: 'app/api', + unmatchedOnly: true, + }); + }); + + it('coerces unmatchedOnly false from string', () => { + const p = parseResourceUri('gitnexus://group/g1/contracts?unmatchedOnly=false'); + expect(p.kind).toBe('group'); + if (p.kind !== 'group' || p.resourceType !== 'contracts') throw new Error('unexpected'); + expect(p.contractsFilter.unmatchedOnly).toBe(false); + }); + + it('parses group status', () => { + const p = parseResourceUri('gitnexus://group/my/product/status'); + expect(p).toEqual({ + kind: 'group', + groupName: 'my/product', + resourceType: 'status', + }); + }); + + it('round-trips repo URI like legacy regex', () => { + const p = parseResourceUri('gitnexus://repo/my%20project/schema'); + expect(p).toEqual({ + kind: 'repo', + repoName: 'my project', + resourceType: 'schema', + }); + }); + + it('rejects unknown group resource tail', () => { + expect(() => parseResourceUri('gitnexus://group/foo/bar')).toThrow('Unknown group resource'); + }); +}); + // ─── readResource URI parsing ──────────────────────────────────────── describe('readResource', () => { @@ -149,6 +213,22 @@ describe('readResource', () => { expect(result).toContain('No repositories indexed'); }); + it('routes group contracts resource through backend', async () => { + const backend = createMockBackend(); + const uri = 'gitnexus://group/g1/contracts?type=http&unmatchedOnly=true'; + await readResource(uri, backend); + expect(backend.readGroupContractsResource).toHaveBeenCalledWith('g1', { + type: 'http', + unmatchedOnly: true, + }); + }); + + it('routes group status resource through backend', async () => { + const backend = createMockBackend(); + await readResource('gitnexus://group/acme/status', backend); + expect(backend.readGroupStatusResource).toHaveBeenCalledWith('acme'); + }); + it('routes gitnexus://repo/{name}/context correctly', async () => { const backend = createMockBackend({ context: {