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
This commit is contained in:
ivkond 2026-04-19 13:37:30 +03:00
parent b18d2ecae6
commit 90550bf4da
5 changed files with 293 additions and 23 deletions

6
.gitignore vendored
View file

@ -100,4 +100,8 @@ gitnexus/vendor/**/node_modules/
.swarm/
local_docs/
local_docs/
# Local agent scratch / review prompts (never commit)
.tmp/
.agents/

View file

@ -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<string> {
try {
const params: Record<string, unknown> = { 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<string> {
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.

View file

@ -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<string> {
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');
}

View file

@ -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<string, unknown>;
expect(typeof callArg.repo === 'string' ? (callArg.repo as string).startsWith('@') : false).toBe(false);
});
});
});

View file

@ -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<Record<string, any>> = {}): 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: {