Skip to content
Merged
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
9 changes: 5 additions & 4 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ Only update the `Status` field — do not modify any other frontmatter or prompt

<!-- BEGIN:REPO:current-state -->
## Current State
Branch: `fix/structured-output` — PR #8 open, awaiting review, auto squash merge enabled.
Released: `1.0.0-preview.2` on npm. `1.0.0-preview.3` staged — will publish on PR merge.
Branch: `feature/simplify-schema` — PR #10 open, corrected with steps array and chaining logic restored.
Released: `1.0.0-preview.2` on npm. `1.0.0-preview.3` staged on `fix/structured-output` (PR #8).
<!-- END:REPO:current-state -->

<!-- BEGIN:REPO:architecture -->
Expand Down Expand Up @@ -104,9 +104,8 @@ Released: `1.0.0-preview.2` on npm. `1.0.0-preview.3` staged — will publish on
| `schema.ts` | Input/output Zod schemas |
| `types.ts` | TypeScript types (inferred from schemas) |
| `builtinRules.ts` | 13 built-in validation rules |
| `validate.ts` | Rule runner — checks all rules against all steps |
| `validate.ts` | Rule runner — checks all rules against flat Command[] |
| `hasShortFlag.ts` | Short flag detection helper |
| `extractCommands.ts` | Extract Command objects from Step (for validation) |
| `stripAnsi.ts` | ANSI escape sequence removal |
| `consts.ts` | Tool name, server name, description |
| `entry/cli.ts` | CLI entry point (bin: `mcp-exec`) |
Expand Down Expand Up @@ -192,6 +191,8 @@ Globs, tilde, `$VAR` in args are NOT expanded — must be literal values. ENOENT
- **ENOENT differentiation** — exit 126 for cwd-not-found, exit 127 for program-not-found. Check cwd existence before spawning.
- **Normalisation layer** — path expansion (`~`, `$VAR`) happens in `normaliseInput.ts` before validation and execution. Only `program`, `cwd`, and `redirect.path` are expanded; `args` are not.
- **merge_stderr on all commands** — `merge_stderr` applies to single commands and pipeline commands equally. No pipeline-only restriction.
- **Schema simplification** — replaced discriminated union (`type: 'command'` / `type: 'pipeline'`) with `StepSchema.commands[]` where length derives behaviour (1 = single, 2+ = pipeline). Steps array and chaining preserved.
- **Step-agnostic validation** — rules see a flat `Command[]` via `flatMap` across all steps. No rule needs to know which step a command belongs to.
<!-- END:REPO:recent-decisions -->

<!-- BEGIN:REPO:extra -->
Expand Down
26 changes: 26 additions & 0 deletions .claude/sessions/2026-03-22.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
### 01:50 — schema simplification correction (BB-011)

- Did:
- Restored `steps` array and chaining logic that were accidentally removed in commit `581c127`
- Added `StepSchema` with `commands: Command[].min(1)` — length derives single vs pipeline (no discriminated union)
- Restored `execStep.ts` dispatcher, step loop in `execute.ts`, and chaining tests (`bail_on_error`, `sequential`)
- Validation remains step-agnostic — `flatMap` extracts all commands from all steps before passing to rules
- All 111 tests pass (19 executor, 7 integration, 54 validation, 9 normalise, 12 expandPath, 10 stripAnsi)
- Files:
- `packages/mcp-exec/src/schema.ts` — added `StepSchema`, restored `steps` on `ExecInputSchema`
- `packages/mcp-exec/src/types.ts` — added `Step` type
- `packages/mcp-exec/src/execStep.ts` — new, dispatches single vs pipeline by commands.length
- `packages/mcp-exec/src/execute.ts` — restored step loop with chaining strategy
- `packages/mcp-exec/src/normaliseInput.ts` — iterates over `steps[*].commands`
- `packages/mcp-exec/src/execToolDefinition.ts` — `flatMap` for validation, per-step labels
- `packages/mcp-exec/src/entry/index.ts` — exports `Step` type
- `packages/mcp-exec/test/executor.spec.ts` — adapted to steps shape, restored chaining tests
- `packages/mcp-exec/test/integration.spec.ts` — adapted to steps shape
- `packages/mcp-exec/test/normaliseInput.spec.ts` — adapted to steps shape
- Decisions:
- Step-agnostic validation — rules see a flat `Command[]` via `flatMap`, matching regardless of which step a command is in
- `extractCommands.ts` not restored — was only needed for discriminated union; `flatMap` on steps is simpler
- `execToolDefinition.ts` now generates per-step text content (one JSON block per result), not just one for the whole execution
- Next: PR #10 updated — needs review. May need version bump and CHANGELOG update before merge.
- Violations: None
- Prompt: `claude-fleet-shellicar/projects/mcp-exec/prompts/bb-011-worker-prompt.md`
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.0.0-preview.4] - 2026-03-22

### Breaking Changes

- Replaced discriminated union (`type: 'command'` / `type: 'pipeline'`) with a unified `commands` array on each step. Single command: one element; pipeline: two or more elements connected via stdout→stdin. The `type` field is removed entirely.
- `ExecRule.check` now receives `Command[]` instead of a `Step`

## [1.0.0-preview.3] - 2026-03-20

### Fixed
Expand All @@ -30,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Built-in validation rules blocking destructive operations including rm, sed -i, git reset, force push, xargs, and sudo
- Pluggable rule system for custom validation

[1.0.0-preview.4]: https://github.com/shellicar/mcp-exec/releases/tag/1.0.0-preview.4
[1.0.0-preview.3]: https://github.com/shellicar/mcp-exec/releases/tag/1.0.0-preview.3
[1.0.0-preview.2]: https://github.com/shellicar/mcp-exec/releases/tag/1.0.0-preview.2
[1.0.0-preview.1]: https://github.com/shellicar/mcp-exec/releases/tag/1.0.0-preview.1
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ import type { ExecRule } from '@shellicar/mcp-exec';

const noNpm: ExecRule = {
name: 'no-npm',
check: (step) => {
if (step.type === 'command' && step.program === 'npm') {
check: (commands) => {
if (commands.some((c) => c.program === 'npm')) {
return 'Use pnpm instead of npm.';
}
return undefined;
Expand Down
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@
"**/inject/*.ts",
"**/entry/*.ts",
"**/scripts/**",
"**/*.spec.ts"
"**/*.spec.ts",
"**/src/*.d.ts"
],
"linter": {
"rules": {
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-exec/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@shellicar/mcp-exec",
"private": false,
"version": "1.0.0-preview.3",
"version": "1.0.0-preview.4",
"type": "module",
"license": "MIT",
"author": "Stephen Hellicar",
Expand Down
49 changes: 24 additions & 25 deletions packages/mcp-exec/src/builtinRules.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { extractCommands } from './extractCommands';
import { hasShortFlag } from './hasShortFlag';
import type { ExecRule } from './types';

export const builtinRules: ExecRule[] = [
{
name: 'no-destructive-commands',
check: (step) => {
check: (commands) => {
const blocked = new Set(['rm', 'rmdir', 'mkfs', 'dd', 'shred']);
for (const cmd of extractCommands(step)) {
for (const cmd of commands) {
if (blocked.has(cmd.program)) {
return `'${cmd.program}' is destructive and irreversible. Ask the user to run it directly.`;
}
Expand All @@ -17,8 +16,8 @@ export const builtinRules: ExecRule[] = [
},
{
name: 'no-xargs',
check: (step) => {
for (const cmd of extractCommands(step)) {
check: (commands) => {
for (const cmd of commands) {
if (cmd.program === 'xargs') {
return 'xargs can execute arbitrary commands on piped input. Write commands explicitly, or use Glob/Grep tools.';
}
Expand All @@ -28,8 +27,8 @@ export const builtinRules: ExecRule[] = [
},
{
name: 'no-sed-in-place',
check: (step) => {
for (const cmd of extractCommands(step)) {
check: (commands) => {
for (const cmd of commands) {
if (cmd.program === 'sed') {
if (cmd.args.includes('--in-place') || hasShortFlag(cmd.args, 'i')) {
return 'sed -i modifies files in-place with no undo. Use the redirect option to write to a new file, or use the Edit tool.';
Expand All @@ -41,8 +40,8 @@ export const builtinRules: ExecRule[] = [
},
{
name: 'no-git-rm',
check: (step) => {
for (const cmd of extractCommands(step)) {
check: (commands) => {
for (const cmd of commands) {
if (cmd.program === 'git' && cmd.args.includes('rm')) {
return 'git rm is destructive and irreversible. Ask the user to run it directly.';
}
Expand All @@ -52,8 +51,8 @@ export const builtinRules: ExecRule[] = [
},
{
name: 'no-git-checkout',
check: (step) => {
for (const cmd of extractCommands(step)) {
check: (commands) => {
for (const cmd of commands) {
if (cmd.program === 'git' && cmd.args.includes('checkout')) {
return 'git checkout can discard uncommitted changes with no undo. Use "git switch" for branches, or ask the user to run it directly.';
}
Expand All @@ -63,8 +62,8 @@ export const builtinRules: ExecRule[] = [
},
{
name: 'no-git-reset',
check: (step) => {
for (const cmd of extractCommands(step)) {
check: (commands) => {
for (const cmd of commands) {
if (cmd.program === 'git' && cmd.args.includes('reset')) {
return 'git reset is destructive and irreversible. Ask the user to run it directly.';
}
Expand All @@ -74,8 +73,8 @@ export const builtinRules: ExecRule[] = [
},
{
name: 'no-force-push',
check: (step) => {
for (const cmd of extractCommands(step)) {
check: (commands) => {
for (const cmd of commands) {
if (cmd.program === 'git' && cmd.args.includes('push')) {
if (cmd.args.some((a) => a === '-f' || a.startsWith('--force'))) {
return 'Force push overwrites remote history with no undo. Use regular "git push", or ask the user to run it directly.';
Expand All @@ -87,8 +86,8 @@ export const builtinRules: ExecRule[] = [
},
{
name: 'no-exe',
check: (step) => {
for (const cmd of extractCommands(step)) {
check: (commands) => {
for (const cmd of commands) {
if (cmd.program.endsWith('.exe')) {
return `'${cmd.program}' — there is no reason to call .exe. Run equivalent commands natively.`;
}
Expand All @@ -98,8 +97,8 @@ export const builtinRules: ExecRule[] = [
},
{
name: 'no-sudo',
check: (step) => {
for (const cmd of extractCommands(step)) {
check: (commands) => {
for (const cmd of commands) {
if (cmd.program === 'sudo') {
return 'sudo is not permitted. Run commands directly.';
}
Expand All @@ -109,8 +108,8 @@ export const builtinRules: ExecRule[] = [
},
{
name: 'no-git-C',
check: (step) => {
for (const cmd of extractCommands(step)) {
check: (commands) => {
for (const cmd of commands) {
if (cmd.program === 'git' && hasShortFlag(cmd.args, 'C')) {
return 'git -C changes the working directory and bypasses auto-approve path checks. Use cwd instead.';
}
Expand All @@ -120,8 +119,8 @@ export const builtinRules: ExecRule[] = [
},
{
name: 'no-pnpm-C',
check: (step) => {
for (const cmd of extractCommands(step)) {
check: (commands) => {
for (const cmd of commands) {
if (cmd.program === 'pnpm' && hasShortFlag(cmd.args, 'C')) {
return 'pnpm -C changes the working directory and bypasses auto-approve path checks. Use cwd instead.';
}
Expand All @@ -131,9 +130,9 @@ export const builtinRules: ExecRule[] = [
},
{
name: 'no-env-dump',
check: (step) => {
check: (commands) => {
const blocked = new Set(['env', 'printenv']);
for (const cmd of extractCommands(step)) {
for (const cmd of commands) {
if (blocked.has(cmd.program) && cmd.args.length === 0) {
return `'${cmd.program}' without arguments would dump all environment variables. Specify which variable to read.`;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp-exec/src/entry/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createExecServer } from '../createExecServer';
import { execToolDefinition } from '../execToolDefinition';
import { ExecInputSchema } from '../schema';
import type { ExecConfig, ExecInput, ExecOutput, ExecRule } from '../types';
import type { Command, ExecConfig, ExecInput, ExecOutput, ExecRule, Step } from '../types';

export type { ExecConfig, ExecInput, ExecOutput, ExecRule };
export type { Command, ExecConfig, ExecInput, ExecOutput, ExecRule, Step };
export { createExecServer, ExecInputSchema, execToolDefinition };
4 changes: 2 additions & 2 deletions packages/mcp-exec/src/execPipeline.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { spawn } from 'node:child_process';
import { createWriteStream, existsSync } from 'node:fs';
import { PassThrough } from 'node:stream';
import type { Command, StepResult } from './types';
import type { PipelineCommands, StepResult } from './types';

/** Execute a pipeline of commands with stdout→stdin piping. */
export async function execPipeline(commands: Command[], cwd: string, timeoutMs?: number): Promise<StepResult> {
export async function execPipeline(commands: PipelineCommands, cwd: string, timeoutMs?: number): Promise<StepResult> {
if (commands.length === 0) {
return { stdout: '', stderr: '', exitCode: 0, signal: null };
}
Expand Down
10 changes: 5 additions & 5 deletions packages/mcp-exec/src/execStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { execCommand } from './execCommand';
import { execPipeline } from './execPipeline';
import type { Step, StepResult } from './types';

/** Execute a single step (command or pipeline). */
/** Execute a single step: one command runs directly, two or more form a pipeline. */
export async function execStep(step: Step, cwd: string, timeoutMs?: number): Promise<StepResult> {
if (step.type === 'command') {
const { type: _, ...cmd } = step;
return execCommand(cmd, cwd, timeoutMs);
const [first, second, ...rest] = step.commands;
if (second == null) {
return execCommand(first, cwd, timeoutMs);
}
return execPipeline(step.commands, cwd, timeoutMs);
return execPipeline([first, second, ...rest], cwd, timeoutMs);
}
39 changes: 20 additions & 19 deletions packages/mcp-exec/src/execToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { execute } from './execute';
import { normaliseInput } from './normaliseInput';
import { ExecInputSchema, ExecOutputSchema } from './schema';
import { stripAnsi } from './stripAnsi';
import type { Command, ExecConfig, ExecInput, ExecOutput, ExecuteResult } from './types';
import type { ExecConfig, ExecInput, ExecOutput, ExecuteResult } from './types';
import { validate } from './validate';

type TextContent = { type: 'text'; text: string };
Expand All @@ -18,7 +18,8 @@ export const execToolDefinition = (server: McpServer, config?: ExecConfig): void
const rules = config?.rules ?? builtinRules;

const handler: ExecToolHandler = async (input) => {
const { allowed, errors } = validate(input.steps, rules);
const allCommands = input.steps.flatMap((s) => s.commands);
const { allowed, errors } = validate(allCommands, rules);
if (!allowed) {
return {
content: [{ type: 'text', text: `BLOCKED:\n${errors.join('\n')}` }],
Expand All @@ -30,28 +31,28 @@ export const execToolDefinition = (server: McpServer, config?: ExecConfig): void
const result = await execute(input, cwd);
const clean = input.stripAnsi ? stripAnsi : (s: string) => s;

const content = input.steps.map((step, i) => {
const r = result.results[i];
const content: TextContent[] = result.results.map((r, i) => {
const step = input.steps[i];
const cmds = step?.commands ?? [];
const label =
step.type === 'command'
? step.program
: `pipeline(${step.commands.map((c: Command) => c.program).join(' | ')})`;
cmds.length === 1 ? (cmds[0]?.program ?? 'unknown') : `pipeline(${cmds.map((c) => c.program).join(' | ')})`;

const stepOutput = JSON.stringify({
step: i + 1,
command: label,
exitCode: r?.exitCode ?? undefined,
stdout: clean(r?.stdout ?? '').trimEnd(),
stderr: clean(r?.stderr ?? '').trimEnd(),
signal: r?.signal ?? undefined,
} satisfies ExecuteResult);

return { type: 'text' as const, text: stepOutput };
return {
type: 'text',
text: JSON.stringify({
step: i + 1,
command: label,
exitCode: r?.exitCode ?? undefined,
stdout: clean(r?.stdout ?? '').trimEnd(),
stderr: clean(r?.stderr ?? '').trimEnd(),
signal: r?.signal ?? undefined,
} satisfies ExecuteResult),
};
});

return {
content: content.length > 0 ? content : [{ type: 'text', text: '(no output)' }],
structuredContent: { results: result.results, success: result.success },
content,
structuredContent: { results: result.results, success: result.success } satisfies ExecOutput,
isError: !result.success,
};
};
Expand Down
7 changes: 6 additions & 1 deletion packages/mcp-exec/src/expandPath.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { homedir } from 'node:os';

/** Expand ~ and $VAR / ${VAR} in a path string. */
export function expandPath(value: string): string {
export function expandPath(value: string): string;
export function expandPath(value: string | undefined): string | undefined;
export function expandPath(value: string | undefined): string | undefined {
if (value == null) {
return undefined;
}
return value
.replace(/^~(?=\/|$)/, homedir())
.replace(/\$\{(\w+)\}|\$(\w+)/g, (_, braced: string, bare: string) => process.env[braced ?? bare] ?? '');
Expand Down
10 changes: 0 additions & 10 deletions packages/mcp-exec/src/extractCommands.ts

This file was deleted.

11 changes: 11 additions & 0 deletions packages/mcp-exec/src/globals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
declare global {
interface Array<T> {
map<U>(
this: [T, ...T[]],
callbackfn: (value: T, index: number, array: [T, ...T[]]) => U,
thisArg?: unknown,
): [U, ...U[]];
}
}

export {};
Loading
Loading