Skip to content

Latest commit

 

History

History
568 lines (482 loc) · 18.4 KB

File metadata and controls

568 lines (482 loc) · 18.4 KB

Advanced Architecture Patterns

Production-proven patterns extracted from Craft Agents, an Agent-native desktop app built entirely with AI assistance.

1. Source/MCP Integration Pattern

SourceServerBuilder

A clean separation of concerns for building MCP server configurations.

/**
 * SourceServerBuilder - builds server configs from sources
 * 
 * Separation of concerns:
 * - SourceCredentialManager: handles credentials
 * - SourceServerBuilder: handles server configuration
 */
export class SourceServerBuilder {
  /**
   * Build MCP server config from a source
   * Supports HTTP/SSE (remote) and stdio (local subprocess) transports
   */
  buildMcpServer(source: LoadedSource, token: string | null): McpServerConfig | null {
    // Handle stdio transport (local subprocess servers)
    if (mcp.transport === 'stdio') {
      return {
        type: 'stdio',
        command: mcp.command,
        args: mcp.args,
        env: mcp.env,
      };
    }

    // Handle HTTP/SSE transport (remote servers)
    const url = normalizeMcpUrl(mcp.url);
    const config: McpServerConfig = {
      type: url.includes('/sse') ? 'sse' : 'http',
      url,
    };

    // Inject authentication header
    if (token) {
      config.headers = { Authorization: `Bearer ${token}` };
    }

    return config;
  }

  /**
   * Build API server with OAuth token auto-refresh support
   */
  async buildApiServer(
    source: LoadedSource,
    credential: ApiCredential | null,
    getToken?: () => Promise<string>  // Token getter for OAuth auto-refresh
  ): Promise<McpServer | null> {
    // Google/Slack APIs - use token getter with auto-refresh
    if (provider === 'google' || provider === 'slack') {
      return createApiServer(config, getToken);
    }
    // Static credential auth
    return createApiServer(config, credential);
  }

  /**
   * Build all servers from sources with credentials
   */
  async buildAll(
    sourcesWithCredentials: SourceWithCredential[],
    getTokenForSource?: (source: LoadedSource) => (() => Promise<string>) | undefined
  ): Promise<BuiltServers> {
    // Returns { mcpServers, apiServers, errors }
  }
}

Key Pattern: Pass token getter functions for OAuth sources to support auto-refresh without rebuilding servers.

Dynamic API Tool Factory

Create a single flexible MCP tool per API that handles any endpoint.

/**
 * Create a single flexible MCP tool for an API configuration.
 * Accepts { path, method, params } and handles auth automatically.
 */
export function createApiTool(config: ApiConfig, credential: ApiCredentialSource) {
  return tool(
    `api_${config.name}`,
    buildToolDescription(config),
    {
      path: z.string().describe('API endpoint path'),
      method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']),
      params: z.record(z.string(), z.unknown()).optional(),
      // IMPORTANT: _intent field for smart summarization
      _intent: z.string().optional().describe(
        'REQUIRED: Describe what you are trying to accomplish (1-2 sentences)'
      ),
    },
    async (args) => {
      // Resolve credential (static or token getter)
      const resolvedCredential = isTokenGetter(credential)
        ? await credential()
        : credential;

      // Build URL and headers with auth
      const url = buildUrl(config.baseUrl, path, method, params, config.auth, resolvedCredential);
      const headers = buildHeaders(config.auth, resolvedCredential, config.defaultHeaders);

      const response = await fetch(url, { method, headers, body: ... });

      // Smart summarization for large responses
      if (estimateTokens(text) > TOKEN_LIMIT) {
        return summarizeLargeResult(text, {
          toolName: `api_${config.name}`,
          path,
          input: params,
          modelIntent: _intent,  // Uses intent for context-aware summarization
        });
      }

      return { content: [{ type: 'text', text }] };
    }
  );
}

Key Patterns:

  1. Single flexible tool instead of one tool per endpoint
  2. _intent field injected into schema for context-aware summarization
  3. Token getter pattern for OAuth auto-refresh
  4. Automatic large response handling with intent-preserved summarization

2. Credential Manager Pattern

Multi-backend credential storage with priority-based resolution.

/**
 * Backend priority:
 *   1. Environment variables (server deployment, read-only)
 *   2. Encrypted file storage (cross-platform, no OS keychain prompts)
 */
export class CredentialManager {
  private backends: CredentialBackend[] = [];
  private writeBackend: CredentialBackend | null = null;
  private initialized = false;
  private initPromise: Promise<void> | null = null;

  /**
   * Lazy initialization - called automatically by all public methods
   */
  private async ensureInitialized(): Promise<void> {
    if (this.initialized) return;
    if (this.initPromise) return this.initPromise;

    this.initPromise = this._doInitialize().catch((err) => {
      this.initPromise = null;  // Allow retry on failure
      throw err;
    });
    await this.initPromise;
  }

  /**
   * Get credential by ID, trying all backends in priority order
   */
  async get(id: CredentialId): Promise<StoredCredential | null> {
    await this.ensureInitialized();

    for (const backend of this.backends) {
      const cred = await backend.get(id);
      if (cred) return cred;
    }
    return null;
  }

  /**
   * Set credential using the write backend
   */
  async set(id: CredentialId, credential: StoredCredential): Promise<void> {
    await this.ensureInitialized();
    if (!this.writeBackend) throw new Error('No writable backend');
    await this.writeBackend.set(id, credential);
  }

  // Convenience methods
  async getApiKey(): Promise<string | null> { ... }
  async getClaudeOAuth(): Promise<string | null> { ... }
  async getWorkspaceOAuth(workspaceId: string): Promise<OAuthCredentials | null> { ... }

  /** Check if credential is expired (with 5-minute buffer) */
  isExpired(credential: StoredCredential): boolean {
    if (!credential.expiresAt) return false;
    return Date.now() > credential.expiresAt - 5 * 60 * 1000;
  }
}

// Singleton pattern
let manager: CredentialManager | null = null;
export function getCredentialManager(): CredentialManager {
  if (!manager) manager = new CredentialManager();
  return manager;
}

Key Patterns:

  1. Priority-based backend resolution - environment vars first, then encrypted storage
  2. Lazy initialization with race protection - initPromise prevents concurrent init
  3. Typed credential IDs - { type: 'anthropic_api_key' } or { type: 'workspace_oauth', workspaceId }
  4. Expiry buffer - 5-minute buffer before actual expiry
  5. Convenience methods - typed wrappers for common credential types

3. Permission Mode System

Three-level permission system with session-scoped state isolation.

/**
 * Permission Modes:
 * - 'safe': Read-only exploration mode (blocks writes, never prompts)
 * - 'ask': Ask for permission on dangerous operations (default)
 * - 'allow-all': Skip all permission checks
 */
export type PermissionMode = 'safe' | 'ask' | 'allow-all';

/**
 * Manager for per-session permission mode state.
 * Each session has its own state - NO GLOBAL STATE.
 */
class ModeManager {
  private states: Map<string, ModeState> = new Map();
  private callbacks: Map<string, ModeCallbacks> = new Map();
  private subscribers: Map<string, Set<() => void>> = new Map();

  setPermissionMode(sessionId: string, mode: PermissionMode): void {
    const newState = { ...this.getState(sessionId), permissionMode: mode };
    this.states.set(sessionId, newState);

    // Notify internal callbacks
    this.callbacks.get(sessionId)?.onStateChange?.(newState);
    // Notify React subscribers (useSyncExternalStore)
    this.subscribers.get(sessionId)?.forEach(cb => cb());
  }

  /**
   * Subscribe for React useSyncExternalStore pattern
   */
  subscribe(sessionId: string, callback: () => void): () => void {
    this.subscribers.get(sessionId)?.add(callback);
    return () => this.subscribers.get(sessionId)?.delete(callback);
  }
}

export const modeManager = new ModeManager();

AST-Based Bash Validation

/**
 * Get detailed reason why a bash command was rejected.
 * Uses AST-based validation to properly handle compound commands.
 */
export function getBashRejectionReason(
  command: string,
  config: ToolCheckConfig
): BashRejectionReason | null {
  // Step 1: Check control characters (before parsing)
  const controlChar = hasControlCharacters(command);
  if (controlChar) return { type: 'control_char', ... };

  // Step 2: AST-based validation
  // Handles compound commands like `git status && git log`
  const astResult = validateBashCommand(command, config.readOnlyBashPatterns);

  if (astResult.allowed) return null;

  // Step 3: Convert AST rejection to detailed reason
  switch (astResult.reason.type) {
    case 'pipeline': return { type: 'dangerous_operator', operator: '|', ... };
    case 'redirect': return { type: 'dangerous_operator', operator: reason.op, ... };
    case 'unsafe_command':
      // Find relevant patterns to help agent understand
      const relevantPatterns = findRelevantPatterns(command, config.readOnlyBashPatterns);
      const mismatchAnalysis = analyzePatternMismatch(command, config.readOnlyBashPatterns);
      return { type: 'no_safe_pattern', command, relevantPatterns, mismatchAnalysis };
  }
}

Tool Permission Check

/**
 * Centralized check: should a tool be allowed based on permission mode?
 */
export function shouldAllowToolInMode(
  toolName: string,
  toolInput: unknown,
  mode: PermissionMode,
  options?: { plansFolderPath?: string; permissionsContext?: PermissionsContext }
): ToolCheckResult {
  // Allow-all: no restrictions
  if (mode === 'allow-all') return { allowed: true };

  // Ask mode: all allowed (user will be prompted)
  if (mode === 'ask') return { allowed: true };

  // Safe mode: check against read-only allowlist
  if (ALWAYS_ALLOWED_TOOLS.has(toolName)) return { allowed: true };

  // Bash: detailed rejection with pattern analysis
  if (toolName === 'Bash') {
    const rejection = getBashRejectionReason(command, config);
    if (!rejection) return { allowed: true };
    return { allowed: false, reason: formatBashRejectionMessage(rejection, config) };
  }

  // Write/Edit: allow if targeting plans folder
  if (toolName === 'Write' || toolName === 'Edit') {
    if (filePath.startsWith(options?.plansFolderPath)) return { allowed: true };
    if (matchesAllowedWritePath(filePath, config.allowedWritePaths)) return { allowed: true };
  }

  // MCP tools: check read-only patterns
  if (toolName.startsWith('mcp__')) {
    if (isReadOnlyMcpToolWithConfig(toolName, config)) return { allowed: true };
    return { allowed: false, reason: 'MCP write operations blocked in Explore mode' };
  }

  // API tools: GET always allowed, mutations need whitelist
  if (toolName.startsWith('api_')) {
    if (method === 'GET') return { allowed: true };
    if (isApiCallAllowedWithConfig(method, path, config)) return { allowed: true };
    return { allowed: false, reason: `API ${method} blocked in Explore mode` };
  }

  return { allowed: true };
}

Key Patterns:

  1. Session-scoped state - no global contamination
  2. React integration - subscribe() for useSyncExternalStore
  3. AST-based validation - handles compound commands correctly
  4. Detailed rejection messages - includes relevant patterns and suggestions
  5. Plans folder exception - always allow writes to plans directory

4. Headless Execution Pattern

Non-interactive query execution for automation.

/**
 * HeadlessRunner executes queries in non-interactive mode.
 *
 * Handles interactions automatically:
 * - Permissions: based on policy (deny-all, allow-safe, allow-all)
 * - Questions: returns empty answers
 * - Auth: fails if credentials missing
 */
export class HeadlessRunner {
  /**
   * Map headless permission policy to PermissionMode
   */
  private policyToPermissionMode(policy: HeadlessConfig['permissionPolicy']): PermissionMode {
    switch (policy) {
      case 'allow-all': return 'allow-all';
      case 'allow-safe': return 'ask';
      case 'deny-all':
      default: return 'safe';
    }
  }

  /**
   * Run with streaming events
   */
  async *runStreaming(): AsyncGenerator<HeadlessEvent> {
    // Wrap prompt with headless mode XML tags
    const wrappedPrompt = `<headless_mode tools_usage="no-interactive-tools" safe_mode="disabled">
${this.config.prompt}
</headless_mode>`;

    for await (const event of this.agent.chat(wrappedPrompt)) {
      switch (event.type) {
        case 'text_delta':
          yield { type: 'text_delta', text: event.text };
          break;
        case 'tool_start':
          yield { type: 'tool_start', id: event.toolUseId, name: event.toolName, input: event.input };
          break;
        case 'tool_result':
          toolCalls.push({ ... });
          yield { type: 'tool_result', ... };
          break;
        case 'complete':
          yield { type: 'complete', result: { success: true, response, toolCalls, usage } };
          break;
      }
    }
  }

  /**
   * Auto-handle permission requests based on policy
   */
  private setupPermissionHandler(): void {
    this.agent.onPermissionRequest = (request) => {
      if (this.config.permissionPolicy === 'allow-all') {
        this.agent.respondToPermission(request.requestId, true, false);
        return;
      }

      if (this.config.permissionPolicy === 'allow-safe') {
        const baseCommand = request.command.trim().split(/\s+/)[0];
        const allowed = SAFE_COMMANDS.has(baseCommand);
        this.agent.respondToPermission(request.requestId, allowed, false);
        return;
      }

      // deny-all
      this.agent.respondToPermission(request.requestId, false, false);
    };
  }
}

Key Patterns:

  1. Policy-to-mode mapping - separate abstraction for headless vs interactive
  2. XML wrapper for context - signals headless mode to agent
  3. Streaming event generator - AsyncGenerator<HeadlessEvent>
  4. Auto permission handling - based on policy, no user prompts
  5. Session resumption - --session and --session-resume flags

5. Session-Scoped Tools Pattern

Tools that are bound to a specific session with callbacks.

/**
 * Registry mapping session IDs to their callbacks
 */
const sessionScopedToolCallbackRegistry = new Map<string, SessionScopedToolCallbacks>();

export interface SessionScopedToolCallbacks {
  /** Called when a plan is submitted - triggers plan message display */
  onPlanSubmitted?: (planPath: string) => void;
  /**
   * Called when authentication is requested - triggers auth UI and forceAbort.
   * Flow:
   * 1. Tool calls onAuthRequest
   * 2. Session manager creates auth-request message and calls forceAbort
   * 3. User completes auth in UI
   * 4. Auth result sent as "faked user message"
   * 5. Agent resumes and processes result
   */
  onAuthRequest?: (request: AuthRequest) => void;
}

/**
 * Create session-scoped SubmitPlan tool.
 * SessionId is captured at creation time (closure).
 */
export function createSubmitPlanTool(sessionId: string) {
  return tool(
    'SubmitPlan',
    `Submit a plan for user review.
...
**IMPORTANT:** After calling this tool:
- Execution will be **automatically paused** to present the plan
- No further tool calls or text output will be processed
- The conversation will resume when user responds`,
    {
      planPath: z.string().describe('Absolute path to the plan markdown file'),
    },
    async (args) => {
      // Verify file exists
      if (!existsSync(args.planPath)) {
        return { content: [{ type: 'text', text: 'Error: Plan file not found' }] };
      }

      // Store plan file path
      setLastPlanFilePath(sessionId, args.planPath);

      // Get callbacks and notify UI
      const callbacks = getSessionScopedToolCallbacks(sessionId);
      if (callbacks?.onPlanSubmitted) {
        callbacks.onPlanSubmitted(args.planPath);  // This triggers forceAbort
      }

      return { content: [{ type: 'text', text: 'Plan submitted. Waiting for feedback.' }] };
    }
  );
}

/**
 * Create session-scoped OAuth trigger tool.
 * Triggers auth request that pauses execution.
 */
export function createOAuthTriggerTool(sessionId: string, workspaceRootPath: string) {
  return tool(
    'source_oauth_trigger',
    `Start OAuth authentication for an MCP source.
...
**IMPORTANT:** After calling this tool:
- Execution will be **automatically paused** while OAuth completes
- A browser window will open for user authentication`,
    {
      sourceSlug: z.string().describe('The slug of the source to authenticate'),
    },
    async (args) => {
      const source = loadSourceConfig(workspaceRootPath, args.sourceSlug);
      // Validate source...

      const callbacks = getSessionScopedToolCallbacks(sessionId);
      if (!callbacks?.onAuthRequest) {
        return { content: [{ type: 'text', text: 'No auth handler available' }], isError: true };
      }

      // Build and trigger auth request
      const authRequest: McpOAuthAuthRequest = {
        type: 'oauth',
        requestId: crypto.randomUUID(),
        sessionId,
        sourceSlug: args.sourceSlug,
        sourceName: source.name,
      };

      callbacks.onAuthRequest(authRequest);  // This triggers forceAbort

      return { content: [{ type: 'text', text: 'OAuth requested. Opening browser.' }] };
    }
  );
}

Key Patterns:

  1. Closure capture - sessionId captured at tool creation time
  2. Callback registry - Map<string, Callbacks> for session isolation
  3. ForceAbort pattern - callbacks trigger agent pause, result comes as new message
  4. Tool description warns about pause - agent knows to not add more output after
  5. Request ID for correlation - crypto.randomUUID() ties request to response

Summary: When to Use Each Pattern

Pattern Use When
SourceServerBuilder Building MCP/API server configs from config files
API Tool Factory Creating flexible API tools with auto-auth
CredentialManager Storing/retrieving secrets with multi-backend support
Permission Mode Implementing safe/ask/allow-all permission levels
Headless Runner Running agent queries in CI/automation
Session-Scoped Tools Tools that need to interact with UI or pause execution

Source

These patterns are extracted from Craft Agents, an open-source Agent-native desktop app built with Claude Agent SDK.