Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
"test:loop-cost": "cd tools/loop-cost && npm test",
"test:loop-sync": "cd tools/loop-sync && npm test",
"test:loop-context": "cd tools/loop-context && npm test",
"test:loop-gate": "cd tools/loop-gate && npm test",
"test:mcp-server": "cd tools/mcp-server && npm test",
"test:loop-worktree": "cd tools/loop-worktree && npm test",
"test:tools": "npm run test:loop-audit && npm run test:loop-init && npm run test:loop-cost && npm run test:loop-sync && npm run test:loop-context && npm run test:mcp-server && npm run test:loop-worktree",
"build:tools": "cd tools/loop-audit && npm run build && cd ../loop-init && npm run build && cd ../loop-cost && npm run build && cd ../loop-sync && npm run build && cd ../loop-context && npm run build && cd ../mcp-server && npm run build && cd ../loop-worktree && npm run build",
"test:tools": "npm run test:loop-audit && npm run test:loop-init && npm run test:loop-cost && npm run test:loop-sync && npm run test:loop-context && npm run test:loop-gate && npm run test:mcp-server && npm run test:loop-worktree",
"build:tools": "cd tools/loop-audit && npm run build && cd ../loop-init && npm run build && cd ../loop-cost && npm run build && cd ../loop-sync && npm run build && cd ../loop-context && npm run build && cd ../loop-gate && npm run build && cd ../mcp-server && npm run build && cd ../loop-worktree && npm run build",
"contributors:generate": "node scripts/generate-contributors.mjs",
"contributors:check": "node scripts/generate-contributors.mjs --check"
},
Expand Down
5 changes: 5 additions & 0 deletions scripts/ci-validate-gates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ cd ../loop-context
npm ci
npm test

echo "Building and testing loop-gate…"
cd ../loop-gate
npm ci
npm test

echo "Building and testing mcp-server…"
cd ../mcp-server
npm ci
Expand Down
42 changes: 41 additions & 1 deletion tools/mcp-server/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { resolveProjectRoot, loadRegistry, loadPatternDoc, listSkills, loadSkill, loadState, listStateFiles, loadLoopConfig, loadBudget, loadRunLog, loadSafetyDoc, listPatternDocs, } from './resolver.js';
import path from 'node:path';
import { loadGateConfig, checkGate } from '@cobusgreyling/loop-gate';
import { resolveProjectRoot, loadRegistry, loadPatternDoc, listSkills, loadSkill, loadState, listStateFiles, loadLoopConfig, loadBudget, loadRunLog, loadSafetyDoc, listPatternDocs, loadGatePolicy, } from './resolver.js';
const server = new McpServer({
name: 'loop-engineering',
version: '1.0.0',
Expand Down Expand Up @@ -63,6 +65,17 @@ server.resource('safety', 'loop://safety', { description: 'Safety documentation
}],
};
});
server.resource('gate', 'loop://gate', { description: 'gate.yaml — Machine-readable safety policy (path denylist, auto-merge allowlist)' }, async () => {
const root = await resolveProjectRoot();
const content = await loadGatePolicy(root);
return {
contents: [{
uri: 'loop://gate',
mimeType: 'text/yaml',
text: content ?? 'No gate.yaml policy found',
}],
};
});
// ── Resource Templates (dynamic) ───────────────────────────────────
server.resource('pattern', new ResourceTemplate('loop://patterns/{patternId}', { list: undefined }), { description: 'Full pattern documentation by ID (e.g. daily-triage, pr-babysitter, ci-sweeper)' }, async (uri, variables) => {
const patternId = variables.patternId;
Expand Down Expand Up @@ -308,6 +321,33 @@ server.tool('loop_estimate_cost', 'Estimate daily token cost for a pattern at a
}
return { content: [{ type: 'text', text: lines.join('\n') }] };
});
server.tool('loop_gate_check', 'Evaluate a proposed change against the static safety policy (gate.yaml) to see if it triggers the denylist or exceeds thresholds', {
action: z.enum(['commit', 'merge', 'auto-merge']).describe('What the loop is about to do'),
paths: z.array(z.string()).describe('List of changed file paths'),
}, async ({ action, paths }) => {
const root = await resolveProjectRoot();
const gateFile = path.join(root, 'gate.yaml');
let config;
try {
config = await loadGateConfig(gateFile);
}
catch (err) {
return { content: [{ type: 'text', text: `Error loading gate policy: ${err.message}` }] };
}
const decision = checkGate({ config, action: action, paths });
const lines = [
`## Gate Decision: ${decision.allowed ? '✅ ALLOWED' : '❌ BLOCKED'}`,
`- **Trigger:** ${decision.trigger}`,
`- **Reason:** ${decision.reason}`
];
if (decision.matchedPaths.length > 0) {
lines.push('', '**Matched Paths:**');
for (const p of decision.matchedPaths) {
lines.push(`- ${p}`);
}
}
return { content: [{ type: 'text', text: lines.join('\n') }] };
});
// ── Start ──────────────────────────────────────────────────────────
async function main() {
const transport = new StdioServerTransport();
Expand Down
1 change: 1 addition & 0 deletions tools/mcp-server/dist/resolver.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,5 @@ export declare function loadLoopConfig(root: string): Promise<string | null>;
export declare function loadBudget(root: string): Promise<string | null>;
export declare function loadRunLog(root: string): Promise<string | null>;
export declare function loadSafetyDoc(root: string): Promise<string | null>;
export declare function loadGatePolicy(root: string): Promise<string | null>;
export declare function listPatternDocs(root: string): Promise<string[]>;
3 changes: 3 additions & 0 deletions tools/mcp-server/dist/resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ export async function loadSafetyDoc(root) {
}
return null;
}
export async function loadGatePolicy(root) {
return readFileIfExists(path.join(root, 'gate.yaml'));
}
export async function listPatternDocs(root) {
const patternsDir = path.join(root, 'patterns');
if (!(await fileExists(patternsDir)))
Expand Down
61 changes: 61 additions & 0 deletions tools/mcp-server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions tools/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@
"access": "public"
},
"dependencies": {
"@cobusgreyling/loop-gate": "file:../loop-gate",
"@modelcontextprotocol/sdk": "^1.12.1",
"minimatch": "^10.2.5",
"yaml": "^2.8.0",
"zod": "^3.25 || ^4.0"
},
Expand Down
57 changes: 57 additions & 0 deletions tools/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import path from 'node:path';
import { loadGateConfig, checkGate } from '@cobusgreyling/loop-gate';
import {
resolveProjectRoot,
loadRegistry,
Expand All @@ -16,6 +18,7 @@ import {
loadRunLog,
loadSafetyDoc,
listPatternDocs,
loadGatePolicy,
} from './resolver.js';

const server = new McpServer({
Expand Down Expand Up @@ -110,6 +113,23 @@ server.resource(
},
);

server.resource(
'gate',
'loop://gate',
{ description: 'gate.yaml — Machine-readable safety policy (path denylist, auto-merge allowlist)' },
async () => {
const root = await resolveProjectRoot();
const content = await loadGatePolicy(root);
return {
contents: [{
uri: 'loop://gate',
mimeType: 'text/yaml',
text: content ?? 'No gate.yaml policy found',
}],
};
},
);

// ── Resource Templates (dynamic) ───────────────────────────────────

server.resource(
Expand Down Expand Up @@ -426,6 +446,43 @@ server.tool(
},
);

server.tool(
'loop_gate_check',
'Evaluate a proposed change against the static safety policy (gate.yaml) to see if it triggers the denylist or exceeds thresholds',
{
action: z.enum(['commit', 'merge', 'auto-merge']).describe('What the loop is about to do'),
paths: z.array(z.string()).describe('List of changed file paths'),
},
async ({ action, paths }) => {
const root = await resolveProjectRoot();
const gateFile = path.join(root, 'gate.yaml');

let config;
try {
config = await loadGateConfig(gateFile);
} catch (err: any) {
return { content: [{ type: 'text' as const, text: `Error loading gate policy: ${err.message}` }] };
}

const decision = checkGate({ config, action: action as any, paths });

const lines = [
`## Gate Decision: ${decision.allowed ? '✅ ALLOWED' : '❌ BLOCKED'}`,
`- **Trigger:** ${decision.trigger}`,
`- **Reason:** ${decision.reason}`
];

if (decision.matchedPaths.length > 0) {
lines.push('', '**Matched Paths:**');
for (const p of decision.matchedPaths) {
lines.push(`- ${p}`);
}
}

return { content: [{ type: 'text' as const, text: lines.join('\n') }] };
},
);

// ── Start ──────────────────────────────────────────────────────────

async function main() {
Expand Down
4 changes: 4 additions & 0 deletions tools/mcp-server/src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ export async function loadSafetyDoc(root: string): Promise<string | null> {
return null;
}

export async function loadGatePolicy(root: string): Promise<string | null> {
return readFileIfExists(path.join(root, 'gate.yaml'));
}

export async function listPatternDocs(root: string): Promise<string[]> {
const patternsDir = path.join(root, 'patterns');
if (!(await fileExists(patternsDir))) return [];
Expand Down
58 changes: 57 additions & 1 deletion tools/mcp-server/test/server.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
loadSafetyDoc,
listPatternDocs,
loadPatternDoc,
loadGatePolicy,
} from '../dist/resolver.js';

let tmpRoot;
Expand Down Expand Up @@ -98,6 +99,19 @@ async function setup() {
'# Safety\n\n## Path Denylists\n- .env\n- credentials\n',
);

// gate.yaml
await writeFile(
path.join(tmpRoot, 'gate.yaml'),
`version: 1
denylist:
- ".env"
- "**/secrets/**"
maxFiles: 10
autoMergeAllowlist:
- "docs/**"
`,
);

return tmpRoot;
}

Expand Down Expand Up @@ -296,6 +310,18 @@ test('loadSafetyDoc reads docs/safety.md', async () => {
}
});

test('loadGatePolicy reads gate.yaml', async () => {
const root = await setup();
try {
const gate = await loadGatePolicy(root);
assert.ok(gate);
assert.ok(gate.includes('version: 1'));
assert.ok(gate.includes('.env'));
} finally {
await cleanup();
}
});

test('listPatternDocs finds .md files in patterns/', async () => {
const root = await setup();
try {
Expand Down Expand Up @@ -357,7 +383,7 @@ test('server lists all tools over stdio', async () => {
try {
const res = await callServer(root, [{ id: 1, method: 'tools/list', params: {} }]);
const names = res.get(1).result.tools.map(t => t.name);
assert.equal(names.length, 8);
assert.equal(names.length, 9);
assert.ok(names.includes('loop_list_patterns'));
assert.ok(names.includes('loop_estimate_cost'));
} finally {
Expand Down Expand Up @@ -408,3 +434,33 @@ test('pattern resource is readable over stdio', async () => {
await cleanup();
}
});

test('gate resource is readable over stdio', async () => {
const root = await setup();
try {
const res = await callServer(root, [{
id: 1, method: 'resources/read',
params: { uri: 'loop://gate' },
}]);
const text = res.get(1).result.contents[0].text;
assert.ok(text.includes('version: 1'));
assert.ok(text.includes('denylist:'));
} finally {
await cleanup();
}
});

test('loop_gate_check tool returns policy decision', async () => {
const root = await setup();
try {
const res = await callServer(root, [{
id: 1, method: 'tools/call',
params: { name: 'loop_gate_check', arguments: { action: 'commit', paths: ['.env', 'src/main.ts'] } },
}]);
const text = res.get(1).result.content[0].text;
assert.ok(text.includes('BLOCKED'));
assert.ok(text.includes('denylist'));
} finally {
await cleanup();
}
});