mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-19 00:03:33 +00:00
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:
parent
b18d2ecae6
commit
90550bf4da
5 changed files with 293 additions and 23 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -100,4 +100,8 @@ gitnexus/vendor/**/node_modules/
|
|||
|
||||
.swarm/
|
||||
|
||||
local_docs/
|
||||
local_docs/
|
||||
|
||||
# Local agent scratch / review prompts (never commit)
|
||||
.tmp/
|
||||
.agents/
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue