Skip to content
draft Visibility internal Owner erik@uvilo.com Approver _ Created 2026-05-17 Updated 2026-05-17

Migrate to uvilo-mono Plan 1

Scope: Migrate custom MCP servers from uvilo-os into uvilo-mono as standalone apps, build the McpServerManager process lifecycle manager inside BotCraft, integrate MCP tools into the Vercel AI SDK inference path, and implement deferred tool discovery. Covers Research §3 (External MCP Server Lifecycle Management) and §7 (Deployment Architecture).


Source Repository Locations

All current MCP server source code lives in uvilo-os at Forge/Configs/MCP_Servers/:

ServerLanguageSource PathBuild
forge-bashTypeScriptForge/Configs/MCP_Servers/forge-bash/src/index.tsesbuild → dist/index.js
forge-discoveryTypeScriptForge/Configs/MCP_Servers/forge-discovery/src/index.tsesbuild → dist/index.js
uvilo-typesensePythonForge/Configs/MCP_Servers/typesense-mcp.pyInterpreted
uvilo-filesystemnpx package@modelcontextprotocol/server-filesystem (npm)npx -y
uvilo-trashPythonForge/Configs/MCP_Servers/uvilo-trash.pyInterpreted
uvilo-shellPythonForge/Configs/MCP_Servers/uvilo-shell.pyInterpreted (OUTDATED — drop)
forge-spawnTypeScriptForge/Configs/MCP_Servers/forge-spawn/src/index.tsesbuild (OUTDATED — drop, reimplement later)

Target monorepo: uvilo-mono at /workspace/erik/uvilo-mono/ (GitHub: ErikDakoda/uvilo-mono)

Current uvilo-mono app layout: apps/bot-craft, apps/uvilo-ai, apps/uvilo-mcp

Package namespace: packages/@erikdakoda/* (e.g., @erikdakoda/mcp, @erikdakoda/ai-tool, @erikdakoda/bot, @erikdakoda/inference)


Architecture Decisions (from Research §3 and §7)

  1. DB-stored MCP server configs + in-process lifecycle manager (§3 Option A) — Store server configs (command, args, env, timeout, instructions) in a new McpServer model. Add a per-Bot join table BotMcpServer for binding. Build an McpServerManager singleton inside BotCraft that spawns stdio processes on demand.

  2. All MCP servers managed by BotCraft (§7 Option A) — The McpServerManager spawns stdio processes on demand, tracks health, discovers tools. Custom MCP servers (forge-bash, forge-discovery, etc.) are always-on; third-party servers start on first use and idle-timeout. One Railway service, one container.

  3. Vercel AI SDK createMCPClient — Use @ai-sdk/mcp’s createMCPClient with stdio transport to connect to MCP servers. At inference time, ChatPostHandler collects both native AI tools and MCP tools from the Bot’s bound servers.


Task 1 — Set Up MCP App Infrastructure in uvilo-mono

Before any MCP server can be migrated, the monorepo must have app scaffolding, shared build config, and turbo pipeline entries for each MCP server app.

1.1 Add MCP server apps to pnpm workspace

Edit pnpm-workspace.yaml to add the new app entries:

packages:
  # ... existing entries ...
  - 'apps/forge-bash'
  - 'apps/forge-discovery'
  - 'apps/forge-typesense'
  - 'apps/forge-filesystem'

1.2 Create each app directory with standard structure

Each MCP server app follows this structure:

apps/forge-{name}/
├── src/
│   └── index.ts
├── package.json
├── tsconfig.json
└── README.md

1.3 Shared package.json template

Each MCP server app’s package.json should follow this pattern (adapted from the existing forge-bash/forge-discovery configs):

{
  "name": "forge-{name}",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.12.1",
    "zod": "^3.25.76"
  },
  "devDependencies": {
    "@types/node": "^22.19.11",
    "esbuild": "^0.25.3",
    "typescript": "^5.9.3"
  }
}

1.4 Add turbo build tasks

Edit turbo.json to add build tasks for each MCP server app:

"build-forge-bash": {
  "outputs": ["dist/**"]
},
"build-forge-discovery": {
  "outputs": ["dist/**"]
},
"build-forge-typesense": {
  "outputs": ["dist/**"]
},
"build-forge-filesystem": {
  "outputs": ["dist/**"]
}

1.5 Add root-level convenience scripts

Add to root package.json scripts:

"build:forge-bash": "pnpm -F forge-bash build",
"build:forge-discovery": "pnpm -F forge-discovery build",
"build:forge-typesense": "pnpm -F forge-typesense build",
"build:forge-filesystem": "pnpm -F forge-filesystem build",
"build:forge-mcps": "pnpm -F forge-bash build && pnpm -F forge-discovery build && pnpm -F forge-typesense build && pnpm -F forge-filesystem build"

Task 2 — Migrate forge-bash

Direct port of the existing TypeScript forge-bash MCP server. Minimal changes — adapt import paths and ensure it works as a monorepo app.

2.1 Copy source

Source: uvilo-os/Forge/Configs/MCP_Servers/forge-bash/src/index.ts (277 lines)

Create: uvilo-mono/apps/forge-bash/src/index.ts

2.2 Adapt the source

The existing code uses these imports:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod/v4';

Changes needed:

  • Change zod/v4 import to zod (the monorepo uses zod v3, not v4): import { z } from 'zod'
  • Keep all other code as-is. The server logic (chain parsing, rm detection, audit logging, layer-2 output processing, Railway env injection) requires no changes.

2.3 Tool signature (preserve exactly)

The server registers one tool: run

Input: { command: string } — Full command string, including pipes and chain operators.
Output: stdout + stderr + [exit:N | Xms] footer

Key behaviors to preserve:

  • Supports |, &&, ||, ; operators
  • Blocks rm — returns error directing user to uvilo-trash
  • Audit logs to /tmp/forge-bash/audit-{date}.tsv
  • Overflow: truncates at 200 lines / 50KB, writes full output to /tmp/forge-bash/cmd-N.txt
  • Binary guard: detects null bytes / high control-char ratio
  • Railway env injection: reads /proc/1/environ at startup
  • Shell detection: which bash fallback to /bin/sh
  • Timeout: 300s (5 min), max buffer: 50MB

2.4 Verify

After building, test manually:

cd /workspace/erik/uvilo-mono
pnpm -F forge-bash build
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' | node apps/forge-bash/dist/index.js

Task 3 — Migrate forge-discovery

Direct port of the existing TypeScript forge-discovery MCP server. Adapt for monorepo.

3.1 Copy source

Source: uvilo-os/Forge/Configs/MCP_Servers/forge-discovery/src/index.ts (464 lines)

Create: uvilo-mono/apps/forge-discovery/src/index.ts

3.2 Adapt the source

Changes needed:

  • Change zod/v4 import to zod (same as forge-bash)
  • The REPO_PATH constant currently defaults to /workspace/erik/uvilo-oskeep as-is since the server will still read from the uvilo-os repo path on Railway. This is configurable via process.env.REPO_PATH.
  • Keep all tool registrations and logic as-is.

3.3 Tool signatures (preserve exactly)

The server registers these tools:

ToolInputDescription
list_departments(none)List all departments with README summary
list_projects{ department: string }List projects in a department
get_project_phase{ project: string, department: string }Get current project phase
get_project_files{ project: string, department: string }List project files with metadata
list_skills{ department?: string }List available skills
get_skill_details{ skill: string, department?: string }Get full SKILL.md content
find{ query: string }Fuzzy search across departments, projects, skills

3.4 Verify

pnpm -F forge-discovery build
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' | REPO_PATH=/workspace/erik/uvilo-os node apps/forge-discovery/dist/index.js

Task 4 — Rewrite uvilo-typesense as forge-typesense

Rewrite the Python typesense-mcp.py (183 lines) as TypeScript. Rename to forge-typesense.

4.1 Reference implementation

Source: uvilo-os/Forge/Configs/MCP_Servers/typesense-mcp.py

The Python implementation provides 2 tools: search_knowledge and get_file_summary. Both call the Typesense multi_search endpoint.

4.2 Create TypeScript implementation

Create: uvilo-mono/apps/forge-typesense/src/index.ts

Environment variables:

  • TYPESENSE_URL — Typesense server URL (required)
  • TYPESENSE_SEARCH_KEY — API key for search (required)
  • TYPESENSE_COLLECTION — Collection name (default: "uvilo")

Implementation approach:

  • Use Node.js built-in fetch (Node 22+) instead of urllib — no extra HTTP dependency needed
  • Use McpServer from @modelcontextprotocol/sdk/server/mcp.js
  • Use StdioServerTransport from @modelcontextprotocol/sdk/server/stdio.js
  • Use z from zod for tool input schemas

4.3 Tool: search_knowledge

Preserve the exact same input parameters and output format as the Python version:

{
  query: z.string().describe("Search query — keywords or natural language"),
  department: z.string().optional().describe("Filter by department"),
  project: z.string().optional().describe("Filter by project name"),
  type: z.string().optional().describe("Filter by document type"),
  source: z.string().optional().describe("Filter by source: repo or website"),
  status: z.string().optional().describe("Filter by status"),
  visibility: z.string().optional().describe("Filter by visibility"),
  owner: z.string().optional().describe("Filter by owner"),
  limit: z.number().min(1).max(50).default(5).describe("Max results"),
}

Filter construction: Build filter_by string from non-null parameters: department:=Forge && project:=Taxonomy etc.

Search parameters:

{
  q: query,
  query_by: "title,summary,content",
  per_page: limit,
  filter_by: filterString,  // if any filters
}

Output format (preserve exactly):

Found {found} result(s). Showing top {count}:

1. {title}
   Path: {path}
   Summary: {summary}
   Snippet: {snippet}
   Score: {score}

4.4 Tool: get_file_summary

Input: { path: string } — relative file path

Searches Typesense with query_by: "path" and filter_by: "path:={path}". Returns title + summary from the stored document. If no summary, returns message about re-indexing.

4.5 Typesense HTTP call

async function typesenseSearch(params: object): Promise<any> {
  const url = `${TYPESENSE_URL}/multi_search`;
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-TYPESENSE-API-KEY': TYPESENSE_API_KEY,
    },
    body: JSON.stringify(params),
    signal: AbortSignal.timeout(15_000),
  });
  if (!response.ok) {
    const body = await response.text();
    throw new Error(`Typesense HTTP ${response.status}: ${body}`);
  }
  return response.json();
}

4.6 Verify

pnpm -F forge-typesense build
TYPESENSE_URL=... TYPESENSE_SEARCH_KEY=... echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' | node apps/forge-typesense/dist/index.js

Task 5 — Create forge-filesystem

Custom TypeScript replacement for the npx @modelcontextprotocol/server-filesystem package, plus integration of the uvilo-trash Python server’s trash functionality.

5.1 Reference implementations

  1. Current filesystem server: @modelcontextprotocol/server-filesystem (npx package) — provides read_text_file, write_file, edit_file, list_directory, search_files
  2. Current trash server: uvilo-os/Forge/Configs/MCP_Servers/uvilo-trash.py (493 lines) — provides trash_file, trash_directory, untrash_file, untrash_directory, list_trash, empty_trash

5.2 Create TypeScript implementation

Create: uvilo-mono/apps/forge-filesystem/src/index.ts

Environment variables:

  • WORKSPACE_ROOT — Root directory for filesystem access (default: /workspace)
  • REPO_ROOT — Repository root for trash operations (default: /workspace/erik/uvilo-os)

5.3 Filesystem tools (port from @modelcontextprotocol/server-filesystem)

These 5 tools replicate the standard filesystem MCP server. Use Node.js built-in fs module:

ToolInputDescription
read_text_file{ path: string, head?: number, tail?: number }Read file contents (optionally first/last N lines)
write_file{ path: string, content: string }Create or overwrite file
edit_file{ path: string, edits: Array<{oldText: string, newText: string}>, dryRun?: boolean }Line-based edits with diff output
list_directory{ path: string }List files/dirs with [FILE]/[DIR] prefixes
search_files{ path: string, pattern: string, excludePatterns?: string[] }Glob-style recursive file search

Security: Validate that all paths resolve within WORKSPACE_ROOT. Reject path traversal (e.g., ../ escaping the root).

5.4 Trash tools (port from uvilo-trash.py)

Port the 6 trash tools from the Python implementation. Key logic to preserve:

ToolInputDescription
trash_file{ path: string }Move file to .trash/ with timestamp suffix
trash_directory{ path: string }Move directory to .trash/ with timestamp suffix
untrash_file{ path: string }Restore file from trash
untrash_directory{ path: string }Restore directory from trash
list_trash(none)List items in trash with metadata
empty_trash(none)Permanently delete all trash items (irreversible)

Key trash logic to preserve:

  • Trash root: {REPO_ROOT}/.trash/
  • Timestamp format: __YYYY-MM-DDTHH-MM-SS appended before file extension
  • Protected paths: .git, .trash, .internal/node_modules, .internal/dist, .internal/.astro
  • Original directory structure preserved in trash (e.g., Architecture/Old_Doc.md.trash/Architecture/Old_Doc__2026-03-14T18-30-00.md)
  • Cleanup empty parent directories after untrash
  • empty_trash is irreversible — include a confirmation step

5.5 Verify

pnpm -F forge-filesystem build
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' | WORKSPACE_ROOT=/tmp/test-workspace node apps/forge-filesystem/dist/index.js

Task 6 — Integrate Playwright MCP

Add Playwright MCP as a managed server within the monorepo build. Unlike the custom servers, Playwright is a third-party npm package — we need to ensure it’s available in the Docker image.

6.1 Add Playwright as a BotCraft dependency

Since Playwright MCP will be managed by McpServerManager inside the BotCraft container, add it as a dependency of bot-craft:

cd apps/bot-craft
pnpm add -D @playwright/mcp playwright

6.2 Playwright MCP configuration in DB seed

When seeding MCP server configs (Task 7), include:

{
  name: 'playwright',
  command: 'npx',
  args: ['@playwright/mcp@latest', '--headless'],
  timeout: 60000,
  alwaysOn: false,
  instructions: 'Headless Chromium browser. Use browser_navigate for URLs...'
}

6.3 Note on Playwright skill

Reference the existing skill: Forge/Skills/Playwright_MCP/SKILL.md — this contains Forge-specific usage patterns. The tool descriptions from the MCP server itself should be sufficient; the skill content is for Forge’s system prompt.


Task 7 — Build McpServerManager

Core infrastructure: a singleton service inside BotCraft that manages the lifecycle of MCP server processes. This is the heart of Research §3 Option A.

7.1 Create new package: @erikdakoda/mcp-lifecycle

Create: uvilo-mono/packages/@erikdakoda/mcp-lifecycle/

This is a new workspace package (not an app) because it’s imported by BotCraft’s server-side code, not deployed as a standalone process.

Package structure:

packages/@erikdakoda/mcp-lifecycle/
├── src/
│   ├── McpServerManager.ts    # Main singleton
│   ├── McpProcess.ts          # Individual process wrapper
│   ├── types.ts                # Config types, process states
│   └── index.ts                # Public exports
├── package.json
├── tsconfig.json
└── vitest.config.ts

7.2 Dependencies

{
  "name": "@erikdakoda/mcp-lifecycle",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "dependencies": {
    "@ai-sdk/mcp": "latest",
    "@modelcontextprotocol/sdk": "^1.12.1",
    "ai": "6.0.141",
    "zod": "^3.25.76"
  }
}

7.3 McpProcess — individual process wrapper

interface McpProcessConfig {
  name: string;
  command: string;
  args: string[];
  env?: Record<string, string>;
  timeout: number;          // tool call timeout in ms
  initTimeout: number;      // startup timeout in ms
  alwaysOn: boolean;        // if true, start with BotCraft and never idle-stop
  idleTimeoutMs: number;   // idle shutdown timeout (default: 60000)
  instructions?: string;    // server instructions for LLM context
}

type ProcessState = 'stopped' | 'starting' | 'running' | 'stopping' | 'error';

Each McpProcess wraps:

  1. A child_process.ChildProcess (the stdio MCP server)
  2. A Vercel AI SDK MCPClient created via createMCPClient with StdioClientTransport
  3. Health-checking (ping on initialize)
  4. Idle timer management

Key methods:

  • start() — Spawn process, create MCP client, call initialize, discover tools
  • stop() — Close MCP client, kill process
  • getTools() — Return cached tool definitions from this server
  • healthCheck() — Ping the server to verify it’s responsive
  • getState() — Return current ProcessState

7.4 McpServerManager — singleton

class McpServerManager {
  private processes: Map<string, McpProcess>;
  private configs: Map<string, McpProcessConfig>;

  // Load configs from DB at BotCraft startup
  async loadConfigs(): Promise<void>;

  // Start a specific server (lazy or eager)
  async startServer(name: string): Promise<void>;

  // Stop a specific server
  async stopServer(name: string): Promise<void>;

  // Get tools from a specific server (lazy-starts if not running)
  async getTools(serverName: string): Promise<Record<string, Tool>>;

  // Get all tools from all servers bound to a Bot
  async getToolsForBot(botId: string): Promise<Record<string, Tool>>;

  // Start all "alwaysOn" servers
  async startAlwaysOnServers(): Promise<void>;

  // Stop all servers (graceful shutdown)
  async stopAll(): Promise<void>;

  // Periodic health check + idle cleanup
  startHealthCheckLoop(intervalMs?: number): void;
}

Lifecycle rules (from Research §3.3):

  1. Lazy-start servers on first tool invocation for that Bot’s session
  2. Keep servers alive for the duration of the inference session
  3. Support explicit start/stop via API
  4. Health-check idle servers and shut them down after a configurable timeout (default: 60s)

7.5 Vercel AI SDK integration

Use @ai-sdk/mcp’s createMCPClient for each managed process:

import { createMCPClient } from '@ai-sdk/mcp';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

const mcpClient = await createMCPClient({
  transport: new StdioClientTransport({
    command: config.command,
    args: config.args,
    env: { ...process.env, ...config.env },
  }),
});

const tools = await mcpClient.tools();

Important: The StdioClientTransport from @modelcontextprotocol/sdk spawns the process internally. The McpProcess wrapper must coordinate with this — either let the transport manage the process, or manage it externally and pass the already-running process. The simplest approach: let StdioClientTransport handle process spawning, and track the process reference for health checks and idle shutdown.

7.6 DB models

Add two new models via the existing ZenStack/Prisma schema:

McpServer model:

model McpServer {
  id          String   @id @cuid
  name        String   @unique       // e.g., "forge-bash"
  command     String                 // e.g., "node"
  args        String[]               // e.g., ["apps/forge-bash/dist/index.js"]
  env         Json?                  // environment variables
  timeout     Int      @default(30000)
  initTimeout Int      @default(30000)
  alwaysOn    Boolean  @default(false)
  idleTimeoutMs Int    @default(60000)
  instructions String?              // server-level instructions for LLM
  botBindings BotMcpServer[]
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

BotMcpServer join table:

model BotMcpServer {
  id         String    @id @cuid
  botId      String
  mcpServerId String
  enabled     Boolean   @default(true)
  toolFilter  String[]  @default([])   // empty = all tools; non-empty = whitelist
  bot        Bot        @relation(fields: [botId], references: [id])
  mcpServer  McpServer  @relation(fields: [mcpServerId], references: [id])

  @@unique([botId, mcpServerId])
}

7.7 Seed script

Create a seed script (packages/@erikdakoda/mcp-lifecycle/seed/) that populates McpServer and BotMcpServer for the 4 custom + Playwright servers:

const mcpServers = [
  {
    name: 'forge-bash',
    command: 'node',
    args: ['apps/forge-bash/dist/index.js'],
    timeout: 330000,
    alwaysOn: true,
    instructions: 'Bash execution in /workspace...',
  },
  {
    name: 'forge-discovery',
    command: 'node',
    args: ['apps/forge-discovery/dist/index.js'],
    env: { REPO_PATH: '/workspace/erik/uvilo-os' },
    timeout: 30000,
    alwaysOn: false,
    instructions: 'Read-only discovery of Uvilo OS project and skill data...',
  },
  {
    name: 'forge-typesense',
    command: 'node',
    args: ['apps/forge-typesense/dist/index.js'],
    env: { TYPESENSE_URL: '${TYPESENSE_URL}', TYPESENSE_SEARCH_KEY: '${TYPESENSE_SEARCH_KEY}' },
    timeout: 30000,
    alwaysOn: false,
    instructions: 'Semantic search across Uvilo OS repo content...',
  },
  {
    name: 'forge-filesystem',
    command: 'node',
    args: ['apps/forge-filesystem/dist/index.js'],
    env: { WORKSPACE_ROOT: '/workspace', REPO_ROOT: '/workspace/erik/uvilo-os' },
    timeout: 30000,
    alwaysOn: true,
    instructions: 'Full Railway workspace at /workspace/...',
  },
  {
    name: 'playwright',
    command: 'npx',
    args: ['@playwright/mcp@latest', '--headless'],
    timeout: 60000,
    alwaysOn: false,
    instructions: 'Headless Chromium browser...',
  },
];

Note: Env vars with ${VAR} syntax should be resolved at runtime by McpServerManager using process.env.

7.8 Verify

  • Unit test: McpProcess can start and stop a simple echo MCP server
  • Integration test: McpServerManager can load configs, start always-on servers, and discover tools
  • The seed script runs without errors

Task 8 — Integrate MCP Tools into ChatPostHandler

Wire McpServerManager into the inference path so that ChatPostHandler passes MCP tools alongside native AI tools to streamText().

8.1 Current inference path

The inference flow is:

  1. apps/bot-craft/src/app/api/ai/chat/route.ts → imports POST from @dakoda/inference/api-handlers/ChatPostHandler
  2. ChatPostHandler calls getAiTools() for native AI tools
  3. ChatPostHandler calls streamText() from Vercel AI SDK with the collected tools

8.2 Modification point

In packages/@erikdakoda/inference/api-handlers/ChatPostHandler.ts, after collecting native AI tools, also collect MCP tools:

import { McpServerManager } from '@erikdakoda/mcp-lifecycle';

// Inside the POST handler:
const nativeTools = await getAiTools();

// Get MCP tools for this Bot
const mcpManager = McpServerManager.getInstance();
const mcpTools = await mcpManager.getToolsForBot(botId);

// Merge: MCP tools override native tools with same name (or error on conflict)
const allTools = { ...nativeTools, ...mcpTools };

// Pass to streamText
const result = streamText({
  model,
  tools: allTools,
  // ...
});

8.3 MCP client lifecycle within a request

Critical: MCP clients should NOT be created/destroyed per request. The McpServerManager maintains persistent connections. Tools are discovered once (on server start or first use) and cached. The mcpClient.tools() returns tool definitions that are compatible with Vercel AI SDK’s tools parameter.

However, the createMCPClient returns an MCPClient that needs to stay alive for the tool invocations to work. The McpProcess wrapper keeps the client alive as long as the process is running.

8.4 Tool invocation flow

When streamText() calls an MCP tool:

  1. Vercel AI SDK routes the call through the MCPClient’s tool handler
  2. MCPClient sends a tools/call request over stdio to the MCP server process
  3. The MCP server executes the tool and returns the result
  4. Result flows back through streamText() to the LLM

This works automatically with Vercel AI SDK’s MCP integration — no custom invocation code needed.

8.5 Verify

  • Start BotCraft locally with MCP servers running
  • Send a chat message that triggers an MCP tool call
  • Verify the tool executes and returns results

Task 9 — Build Deferred Tool Discovery (ToolSearch)

Implement a provider-agnostic tool discovery mechanism similar to LibreChat’s ToolSearch, since not all MCP tools should be loaded into every inference call (token budget concerns).

9.1 Vercel AI SDK analysis

The Vercel AI SDK 6 provides:

  • createMCPClientmcpClient.tools() — discovers ALL tools from a server
  • Anthropic has a built-in toolSearchBm25 tool, but it’s Anthropic-specific
  • No built-in provider-agnostic deferred tool discovery

Conclusion: We must build a custom deferred tool discovery mechanism.

9.2 Design: tool_search AI Tool

Create a native AI tool (@erikdakoda/ai-tool) that serves as a tool catalog:

// Register as a native AI tool, not an MCP tool
{
  name: 'tool_search',
  description: 'Searches deferred tools using BM25 ranking. Multi-word queries supported. Use mcp_server param to filter by server.',
  inputSchema: z.object({
    query: z.string().describe('Search term to find in tool names and descriptions'),
    mcp_server: z.union([z.string(), z.array(z.string())]).optional()
      .describe('Filter to tools from specific MCP server(s)'),
    max_results: z.number().min(1).max(50).default(5)
      .describe('Maximum number of matching tools to return'),
    fields: z.array(z.enum(['name', 'description', 'parameters'])).default(['name', 'description'])
      .describe('Which fields to search'),
  }),
}

9.3 Implementation approach

  1. Tool catalog cache: At McpServerManager startup (or on first tool discovery), store all MCP tool definitions (name, description, input schema) in an in-memory index.

  2. BM25 search: Implement a simple BM25 ranking over tool names and descriptions. This can use a lightweight library or a custom implementation.

  3. Deferred loading pattern:

    • At inference time, only inject the tool_search tool (plus native AI tools) into streamText()
    • When the LLM calls tool_search, return matching tool definitions
    • The LLM then uses the discovered tools in subsequent steps
    • Vercel AI SDK’s maxSteps handles multi-step tool use automatically
  4. Alternative approach — partial tool loading: Instead of fully deferred, load tool names + descriptions for all MCP tools (cheap in tokens), but only load full input schemas when the tool is actually called. This requires a custom tool wrapper.

9.4 Integration with Vercel AI SDK

The key challenge: Vercel AI SDK’s streamText() takes a static tools object. Tools cannot be added mid-stream.

Solution options:

Option A — tool_search returns tool definitions, LLM requests them explicitly:

  • The tool_search tool returns tool metadata (name, description, parameter summary)
  • On next user turn, the LLM can use the discovered tools
  • Limitation: requires an extra turn to discover tools

Option B — Eager load all tool definitions but defer execution:

  • Pass all tool definitions to streamText() (names, descriptions, schemas)
  • This consumes tokens but is the standard Vercel AI SDK pattern
  • No deferred execution needed — all tools are available immediately

Option C — Custom middleware that injects tools on demand:

  • Use prepareStep callback in streamText() to add tools dynamically
  • When the LLM calls tool_search, inject the matching tools into the next step
  • This is the closest to LibreChat’s ToolSearch behavior

Recommendation: Option C — Use Vercel AI SDK’s prepareStep or experimental_toolCallStreaming to dynamically add tools. If the SDK doesn’t support dynamic tool injection mid-stream, fall back to Option A (two-step discovery).

9.5 Verify

  • tool_search("filesystem") returns forge-filesystem tools
  • tool_search("bash", { mcp_server: "forge-bash" }) returns only the run tool
  • LLM can discover and use tools across multiple steps

Task 10 — Update BotCraft Dockerfile and Deployment

Bundle all MCP server apps into the BotCraft Docker image for Railway deployment.

10.1 Current deployment state

BotCraft is deployed as a Next.js standalone app on Railway. The Dockerfile from Research §1.5 already exists conceptually.

10.2 Dockerfile modifications

Add MCP server build steps to the Dockerfile:

FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate

FROM base AS deps
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/bot-craft/package.json ./apps/bot-craft/
COPY apps/forge-bash/package.json ./apps/forge-bash/
COPY apps/forge-discovery/package.json ./apps/forge-discovery/
COPY apps/forge-typesense/package.json ./apps/forge-typesense/
COPY apps/forge-filesystem/package.json ./apps/forge-filesystem/
COPY packages/ ./packages/
RUN pnpm install --frozen-lockfile

FROM base AS builder
WORKDIR /app
COPY --from=deps /app .
COPY . .
# Build MCP servers first, then BotCraft
RUN pnpm build:forge-mcps
RUN pnpm build-bot-craft

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production

# BotCraft standalone output
COPY --from=builder /app/apps/bot-craft/.next/standalone ./
COPY --from=builder /app/apps/bot-craft/.next/static ./apps/bot-craft/.next/static
COPY --from=builder /app/apps/bot-craft/public ./apps/bot-craft/public

# MCP server dist outputs (needed by McpServerManager at runtime)
COPY --from=builder /app/apps/forge-bash/dist ./apps/forge-bash/dist
COPY --from=builder /app/apps/forge-discovery/dist ./apps/forge-discovery/dist
COPY --from=builder /app/apps/forge-typesense/dist ./apps/forge-typesense/dist
COPY --from=builder /app/apps/forge-filesystem/dist ./apps/forge-filesystem/dist

# Playwright browser binaries (if Playwright is bundled)
RUN npx playwright install chromium --with-deps

CMD ["node", "apps/bot-craft/server.js"]

10.3 Railway environment variables

Add to the Railway BotCraft service:

  • TYPESENSE_URL — Typesense server URL
  • TYPESENSE_SEARCH_KEY — Typesense search API key
  • All existing variables from Research §1.5

10.4 Health check

Add a /api/health endpoint to BotCraft that also checks MCP server status:

// apps/bot-craft/src/app/api/health/route.ts
import { McpServerManager } from '@erikdakoda/mcp-lifecycle';

export async function GET() {
  const mcpManager = McpServerManager.getInstance();
  const serverStatuses = mcpManager.getAllStatuses();
  const allHealthy = Object.values(serverStatuses)
    .filter(s => s.alwaysOn)
    .every(s => s.state === 'running');

  return Response.json({
    status: allHealthy ? 'healthy' : 'degraded',
    mcpServers: serverStatuses,
  });
}

Task 11 — Remove Outdated Servers and Clean Up

Drop uvilo-shell and forge-spawn from the configuration. They are replaced by forge-bash (already covers shell) and will be reimplemented differently (out of scope).

11.1 Servers to drop

  • uvilo-shell — replaced by forge-bash (which already provides bash execution with rm-blocking and audit logging)
  • forge-spawn — will be reimplemented as a different architecture later (out of scope for this Plan)

11.2 Actions

  1. Do NOT migrate uvilo-shell.py or forge-spawn/ to uvilo-mono
  2. Remove uvilo-shell and forge-spawn entries from any DB seed data
  3. Remove uvilo-shell and forge-spawn from the LibreChat librechat.yaml (when decommissioning LibreChat)
  4. The uvilo-trash.py Python server is subsumed into forge-filesystem (Task 5) — do NOT migrate as a separate app

11.3 Verify

  • No references to uvilo-shell or forge-spawn in the new monorepo codebase
  • forge-filesystem includes all trash functionality

Dependency Order

Task 1 (Infrastructure) ─┬→ Task 2 (forge-bash)
                          ├→ Task 3 (forge-discovery)
                          ├→ Task 4 (forge-typesense)
                          └→ Task 5 (forge-filesystem)

Task 6 (Playwright) ────────────────┤

                          Task 7 (McpServerManager)

                          Task 8 (ChatPostHandler integration)

                          Task 9 (Deferred tool discovery)

                          Task 10 (Dockerfile/deployment)

                          Task 11 (Cleanup)

Tasks 2–6 can be done in parallel after Task 1 is complete. Task 7 depends on all MCP server apps existing. Tasks 8–11 are sequential.