# Stateful Session Defaults for MCP Tools — Design, Middleware, and Plan

Below is a concise architecture and implementation plan to introduce a session-aware defaults layer that removes repeated tool parameters from public schemas, while keeping all tool logic and tests unchanged.

## Architecture Overview

- **Core idea**: keep logic functions and tests untouched; move argument consolidation into a session-aware interop layer and expose minimal public schemas.
- **Data flow**:
  - Client calls a tool with zero or few args → session middleware merges session defaults → validates with the internal schema → calls the existing logic function.
- **Components**:
  - `SessionStore` (singleton, in-memory): set/get/clear/show defaults.
  - Session-aware tool factory: merges defaults, performs preflight requirement checks (allOf/oneOf), then validates with the tool's internal zod schema.
  - Public vs internal schema: plugins register a minimal "public" input schema; handlers validate with the unchanged "internal" schema.

## Core Types

```typescript
// src/utils/session-store.ts
export type SessionDefaults = {
  projectPath?: string;
  workspacePath?: string;
  scheme?: string;
  configuration?: string;
  simulatorName?: string;
  simulatorId?: string;
  deviceId?: string;
  useLatestOS?: boolean;
  arch?: 'arm64' | 'x86_64';
};
```

## Session Store (singleton)

```typescript
// src/utils/session-store.ts
import { log } from './logger.ts';

class SessionStore {
  private defaults: SessionDefaults = {};

  setDefaults(partial: Partial<SessionDefaults>): void {
    this.defaults = { ...this.defaults, ...partial };
    log('info', '[Session] Defaults set', { keys: Object.keys(partial) });
  }

  clear(keys?: (keyof SessionDefaults)[]): void {
    if (!keys || keys.length === 0) {
      this.defaults = {};
      log('info', '[Session] All defaults cleared');
      return;
    }
    for (const k of keys) delete this.defaults[k];
    log('info', '[Session] Defaults cleared', { keys });
  }

  get<K extends keyof SessionDefaults>(key: K): SessionDefaults[K] {
    return this.defaults[key];
  }

  getAll(): SessionDefaults {
    return { ...this.defaults };
  }
}

export const sessionStore = new SessionStore();
```

## Session-Aware Tool Factory

```typescript
// src/utils/typed-tool-factory.ts (add new helper, keep createTypedTool as-is)
import { z } from 'zod';
import { sessionStore, type SessionDefaults } from './session-store.ts';
import type { CommandExecutor } from './execution/index.ts';
import { createErrorResponse } from './responses/index.ts';
import type { ToolResponse } from '../types/common.ts';

export type SessionRequirement =
  | { allOf: (keyof SessionDefaults)[]; message?: string }
  | { oneOf: (keyof SessionDefaults)[]; message?: string };

function missingFromArgsAndSession(
  keys: (keyof SessionDefaults)[],
  args: Record<string, unknown>,
): string[] {
  return keys.filter((k) => args[k] == null && sessionStore.get(k) == null);
}

export function createSessionAwareTool<TParams>(opts: {
  internalSchema: z.ZodType<TParams>;
  logicFunction: (params: TParams, executor: CommandExecutor) => Promise<ToolResponse>;
  getExecutor: () => CommandExecutor;
  requirements?: SessionRequirement[]; // preflight, friendlier than raw zod errors
}) {
  const { internalSchema, logicFunction, getExecutor, requirements = [] } = opts;

  return async (rawArgs: Record<string, unknown>): Promise<ToolResponse> => {
    try {
      // Merge: explicit args take precedence over session defaults
      const merged: Record<string, unknown> = { ...sessionStore.getAll(), ...rawArgs };

      // Preflight requirement checks (clear message how to fix)
      for (const req of requirements) {
        if ('allOf' in req) {
          const missing = missingFromArgsAndSession(req.allOf, rawArgs);
          if (missing.length > 0) {
            return createErrorResponse(
              'Missing required session defaults',
              `${req.message ?? `Required: ${req.allOf.join(', ')}`}\n` +
                `Set with: session-set-defaults { ${missing.map((k) => `"${k}": "..."`).join(', ')} }`,
            );
          }
        } else if ('oneOf' in req) {
          const missing = missingFromArgsAndSession(req.oneOf, rawArgs);
          // oneOf satisfied if at least one is present in merged
          const satisfied = req.oneOf.some((k) => merged[k] != null);
          if (!satisfied) {
            return createErrorResponse(
              'Missing required session defaults',
              `${req.message ?? `Provide one of: ${req.oneOf.join(', ')}`}\n` +
                `Set with: session-set-defaults { "${req.oneOf[0]}": "..." }`,
            );
          }
        }
      }

      // Validate against unchanged internal schema (logic/api untouched)
      const validated = internalSchema.parse(merged);
      return await logicFunction(validated, getExecutor());
    } catch (error) {
      if (error instanceof z.ZodError) {
        const msgs = error.errors.map((e) => `${e.path.join('.') || 'root'}: ${e.message}`);
        return createErrorResponse(
          'Parameter validation failed',
          `Invalid parameters:\n${msgs.join('\n')}\n` +
            `Tip: set session defaults via session-set-defaults`,
        );
      }
      throw error;
    }
  };
}
```

## Plugin Migration Pattern (Example: build_sim)

Public schema hides session fields; handler uses session-aware factory with internal schema and requirements; logic function unchanged.

```typescript
// src/mcp/tools/simulator/build_sim.ts (key parts only)
import { z } from 'zod';
import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts';
import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';

// Existing internal schema (unchanged)…
const baseOptions = { /* as-is (scheme, simulatorId, simulatorName, configuration, …) */ };
const baseSchemaObject = z.object({
  projectPath: z.string().optional(),
  workspacePath: z.string().optional(),
  ...baseOptions,
});
const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject);
const buildSimulatorSchema = baseSchema
  .refine(/* as-is: projectPath XOR workspacePath */)
  .refine(/* as-is: simulatorId XOR simulatorName */);

export type BuildSimulatorParams = z.infer<typeof buildSimulatorSchema>;

// Public schema = internal minus session-managed fields
const sessionManaged = [
  'projectPath',
  'workspacePath',
  'scheme',
  'configuration',
  'simulatorId',
  'simulatorName',
  'useLatestOS',
] as const;

const publicSchemaObject = baseSchemaObject.omit(
  Object.fromEntries(sessionManaged.map((k) => [k, true])) as Record<string, true>,
);

export default {
  name: 'build_sim',
  description: 'Builds an app for an iOS simulator.',
  schema: publicSchemaObject.shape, // what the MCP client sees
  handler: createSessionAwareTool<BuildSimulatorParams>({
    internalSchema: buildSimulatorSchema,
    logicFunction: build_simLogic,
    getExecutor: getDefaultCommandExecutor,
    requirements: [
      { allOf: ['scheme'], message: 'scheme is required' },
      { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' },
      { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' },
    ],
  }),
};
```

This same pattern applies to `build_run_sim`, `test_sim`, device/macos tools, etc. Public schemas become minimal, while internal schemas and logic remain unchanged.

## New Tool Group: session-management

### session_set_defaults.ts

```typescript
// src/mcp/tools/session-management/session_set_defaults.ts
import { z } from 'zod';
import { sessionStore, type SessionDefaults } from '../../../utils/session-store.ts';
import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';

const schemaObj = z.object({
  projectPath: z.string().optional(),
  workspacePath: z.string().optional(),
  scheme: z.string().optional(),
  configuration: z.string().optional(),
  simulatorName: z.string().optional(),
  simulatorId: z.string().optional(),
  deviceId: z.string().optional(),
  useLatestOS: z.boolean().optional(),
  arch: z.enum(['arm64', 'x86_64']).optional(),
});
type Params = z.infer<typeof schemaObj>;

async function logic(params: Params): Promise<import('../../../types/common.ts').ToolResponse> {
  sessionStore.setDefaults(params as Partial<SessionDefaults>);
  const current = sessionStore.getAll();
  return { content: [{ type: 'text', text: `Defaults updated:\n${JSON.stringify(current, null, 2)}` }] };
}

export default {
  name: 'session-set-defaults',
  description: 'Set session defaults used by other tools.',
  schema: schemaObj.shape,
  handler: createTypedTool(schemaObj, logic, getDefaultCommandExecutor),
};
```

### session_clear_defaults.ts

```typescript
// src/mcp/tools/session-management/session_clear_defaults.ts
import { z } from 'zod';
import { sessionStore } from '../../../utils/session-store.ts';
import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';

const keys = [
  'projectPath','workspacePath','scheme','configuration',
  'simulatorName','simulatorId','deviceId','useLatestOS','arch',
] as const;
const schemaObj = z.object({
  keys: z.array(z.enum(keys)).optional(),
  all: z.boolean().optional(),
});

async function logic(params: z.infer<typeof schemaObj>) {
  if (params.all || !params.keys) sessionStore.clear();
  else sessionStore.clear(params.keys);
  return { content: [{ type: 'text', text: 'Session defaults cleared' }] };
}

export default {
  name: 'session-clear-defaults',
  description: 'Clear selected or all session defaults.',
  schema: schemaObj.shape,
  handler: createTypedTool(schemaObj, logic, getDefaultCommandExecutor),
};
```

### session_show_defaults.ts

```typescript
// src/mcp/tools/session-management/session_show_defaults.ts
import { sessionStore } from '../../../utils/session-store.ts';

export default {
  name: 'session-show-defaults',
  description: 'Show current session defaults.',
  schema: {}, // no args
  handler: async () => {
    const current = sessionStore.getAll();
    return { content: [{ type: 'text', text: JSON.stringify(current, null, 2) }] };
  },
};
```

## Step-by-Step Implementation Plan (Incremental, buildable at each step)

1. **Add SessionStore** ✅ **DONE**
   - New file: `src/utils/session-store.ts`.
   - No existing code changes; run: `npm run build`, `lint`, `test`.
   - Commit checkpoint (after review): see Commit & Review Protocol below.

2. **Add session-management tools** ✅ **DONE**
   - New folder: `src/mcp/tools/session-management` with the three tools above.
   - Register via existing plugin discovery (same pattern as others).
   - Build and test.
   - Commit checkpoint (after review).

3. **Add session-aware tool factory** ✅ **DONE**
   - Add `createSessionAwareTool` to `src/utils/typed-tool-factory.ts` (keep `createTypedTool` intact).
   - Unit tests for requirement preflight and merge precedence.
   - Commit checkpoint (after review).

4. **Migrate 2-3 representative tools**
   - Example: `simulator/build_sim`, `macos/build_macos`, `device/build_device`.
   - Create `publicSchemaObject` (omit session fields), switch handler to `createSessionAwareTool` with requirements.
   - Keep internal schema and logic unchanged. Build and test.
   - Commit checkpoint (after review).

5. **Migrate remaining tools in small batches**
   - Apply the same pattern across simulator/device/macos/test utilities.
   - After each batch: `npm run typecheck`, `lint`, `test`.
   - Commit checkpoint (after review).

6. **Final polish**
   - Add tests for session tools and session-aware preflight error messages.
   - Ensure public schemas no longer expose session parameters globally.
   - Commit checkpoint (after review).

## Standard Testing & DI Checklist (Mandatory)

- Handlers must use dependency injection; tests must never call real executors.
- For validation-only tests, calling the handler is acceptable because Zod validation occurs before executor acquisition.
- For logic tests that would otherwise trigger `getDefaultCommandExecutor`, export the logic function and test it directly (no executor needed if logic doesn’t use one):

```ts
// Example: src/mcp/tools/session-management/session_clear_defaults.ts
export async function sessionClearDefaultsLogic(params: Params): Promise<ToolResponse> { /* ... */ }
export default {
  name: 'session-clear-defaults',
  handler: createTypedTool(schemaObj, sessionClearDefaultsLogic, getDefaultCommandExecutor),
};

// Test: import logic and call directly to avoid real executor
import plugin, { sessionClearDefaultsLogic } from '../session_clear_defaults.ts';
```

- Add tests for the new group and tools:
  - Group metadata test: `src/mcp/tools/session-management/__tests__/index.test.ts`
  - Tool tests: `session_set_defaults.test.ts`, `session_clear_defaults.test.ts`, `session_show_defaults.test.ts`
  - Utils tests: `src/utils/__tests__/session-store.test.ts`
  - Factory tests: `src/utils/__tests__/session-aware-tool-factory.test.ts` covering:
    - Preflight requirements (allOf/oneOf)
    - Merge precedence (explicit args override session defaults)
    - Zod error reporting with helpful tips

- Always run locally before requesting review:
  - `npm run typecheck`
  - `npm run lint`
  - `npm run format:check`
  - `npm run build`
  - `npm run test`
  - Perform a quick manual CLI check (mcpli or reloaderoo) per the Manual Testing section

### Minimal Changes Policy for Tests (Enforced)

- Only make material, essential edits to tests required by the code change (e.g., new preflight error messages or added/removed fields).
- Do not change sample input values or defaults in tests (e.g., flipping a boolean like `preferXcodebuild`) unless strictly necessary to validate behavior.
- Preserve the original intent and coverage of logic-function tests; keep handler vs logic boundaries intact.
- When session-awareness is added, prefer setting/clearing session defaults around tests rather than altering existing assertions or sample inputs.

### Tool Description Policy (Enforced)

- Keep tool descriptions concise (maximum one short sentence).
- Do not mention session defaults, setup steps, examples, or parameter relationships in descriptions.
- Use clear, imperative phrasing (e.g., "Builds an app for an iOS simulator.").
- Apply consistently across all migrated tools; update any tests that assert `description` to match the concise string only.

## Commit & Review Protocol (Enforced)

At the end of each numbered step above:

1. Ensure all checks pass: `typecheck`, `lint`, `format:check`, `build`, `test`; then perform a quick manual CLI test (mcpli or reloaderoo) per the Manual Testing section.
   - Verify tool descriptions comply with the Tool Description Policy (concise, no session-defaults mention).
2. Stage only the files for that step.
3. Prepare a concise commit message focused on the “why”.
4. Request manual review and approval before committing. Do not push.

Example messages per step:

- Step 1 (SessionStore)
  - `chore(utils): add in-memory SessionStore for session defaults`
  - Body: “Introduces singleton SessionStore with set/get/clear/show for session defaults; no behavior changes.”

- Step 2 (session-management tools)
  - `feat(session-management): add set/clear/show session defaults tools and workflow metadata`
  - Body: “Adds tools to manage session defaults and exposes workflow metadata; minimal schemas via typed factory.”

- Step 3 (middleware)
  - `feat(utils): add createSessionAwareTool with preflight requirements and args>session merge`
  - Body: “Session-aware interop layer performing requirements checks and Zod validation against internal schema.”

- Step 6 (tests/final polish)
  - `test(session-management): add tool, store, and middleware tests; export logic for DI`
  - Body: “Covers group metadata, tools, SessionStore, and factory (requirements/merge/errors). No production behavior changes.”

Approval flow:
- After preparing messages and confirming checks, request maintainer approval.
- On approval: commit locally (no push).
- On rejection: revise and re-run checks.

Note on commit hooks and selective commits:
- The pre-commit hook runs format/lint/build and can auto-add or modify files, causing additional files to be included in the commit. If you must commit a minimal subset, skip hooks with: `git commit --no-verify` (use sparingly and run `npm run typecheck && npm run lint && npm run test` manually first).

## Safety, Buildability, Testability

- Logic functions and their types remain unchanged; existing unit tests that import logic directly continue to pass.
- Public schemas shrink; MCP clients see smaller input schemas without session fields.
- Handlers validate with internal schemas after session-defaults merge, preserving runtime guarantees.
- Preflight requirement checks return clear guidance, e.g., "Provide one of: projectPath or workspacePath" + "Set with: session-set-defaults { "projectPath": "..." }".

## Developer Usage

- **Set defaults once**:
  - `session-set-defaults { "workspacePath": "...", "scheme": "App", "simulatorName": "iPhone 16" }`
- **Run tools without args**:
  - `build_sim {}`
- **Inspect/reset**:
  - `session-show-defaults {}`
  - `session-clear-defaults { "all": true }`

## Manual Testing with mcpli (CLI)

The following commands exercise the session workflow end‑to‑end using the built server.

1) Build the server (required after code changes):

```bash
npm run build
```

2) Discover a scheme (optional helper):

```bash
mcpli --raw list-schemes --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" -- node build/cli.js mcp
```

3) Set the session defaults (project/workspace, scheme, and simulator):

```bash
mcpli --raw session-set-defaults \
  --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" \
  --scheme MCPTest \
  --simulatorName "iPhone 16" \
  -- node build/cli.js mcp
```

4) Verify defaults are stored:

```bash
mcpli --raw session-show-defaults -- node build/cli.js mcp
```

5) Run a session‑aware tool with zero or minimal args (defaults are merged automatically):

```bash
# Optionally provide a scratch derived data path and a short timeout
mcpli --tool-timeout=60 --raw build-sim --derivedDataPath "/tmp/XBMCP_DD" -- node build/cli.js mcp
```

Troubleshooting:

- If you see validation errors like “Missing required session defaults …”, (re)run step 3 with the missing keys.
- If you see connect ECONNREFUSED or the daemon appears flaky:
  - Check logs: `mcpli daemon log --since=10m -- node build/cli.js mcp`
  - Restart daemon: `mcpli daemon restart -- node build/cli.js mcp`
  - Clean daemon state: `mcpli daemon clean -- node build/cli.js mcp` then `mcpli daemon start -- node build/cli.js mcp`
  - After code changes, always: `npm run build` then `mcpli daemon restart -- node build/cli.js mcp`

Notes:

- Public schemas for session‑aware tools intentionally omit session fields (e.g., `scheme`, `projectPath`, `simulatorName`). Provide them once via `session-set-defaults` and then call the tool with zero/minimal flags.
- Use `--tool-timeout=<seconds>` to cap long‑running builds during manual testing.
- mcpli CLI normalizes tool names: tools exported with underscores (e.g., `build_sim`) can be invoked with hyphens (e.g., `build-sim`). Copy/paste samples using hyphens are valid because mcpli converts underscores to dashes.

## Next Steps

Would you like me to proceed with Phase 1–3 implementation (store + session tools + middleware), then migrate a first tool (build_sim) and run the test suite?
