AJERISDocs

Bring your own MCP

Design rationale for Ajeris's bring-your-own-MCP architecture, and how to wire custom tools into a per-user container.

Bring Your Own MCP Server, Architecture & Implementation Plan

Status: DESIGN COMPLETE, ready for implementation. Author: Ajeris team Date: 2026-04-12 Depends on:

  • docs/architecture/omnichannel-continuity.md (shipped)
  • docs/architecture/work-tools-and-custom-mcp.md Phase 1 (shipped, Slack tools) Supersedes: Phase 2 section in work-tools-and-custom-mcp.md

1. What this is

A feature that lets Ajeris users connect their OWN MCP servers to their personal agent, so the agent gains access to ANY tools the user has, without Ajeris building every integration natively.

The pitch in one sentence: "If you have an MCP server (yours, your company's, or one from the 11,800+ community servers), you can plug it into Ajeris and talk to it via voice or text."

Who it's for (Persona B from the work-tools doc):

  • Technical founders / CTOs who already have MCP infrastructure (like a power user with a custom toolchain: 70+ tools, 9 agents, Salesforce + Sentry + PostHog + Slack + Google APIs)
  • Developers who've set up MCP servers for their workflow (GitHub, Linear, Notion, databases)
  • Power users who install community MCP servers from the Docker catalog or npm

What it's NOT:

  • Not a replacement for first-party integrations (Slack, Gmail, etc. should stay native for the 90% of users who just want OAuth)
  • Not a marketplace yet (Phase 3 in the roadmap)
  • Not a way to build MCP servers (Ajeris is a CLIENT, not a builder)

2. How users connect a server

2.1 For stdio servers (local / dev)

The user provides:

  • Label: "My Company Tools", "Arvexi Ops", "GitHub"
  • Command: node, python, npx, docker run, etc.
  • Args: path to the server entrypoint or package name
  • Env vars: API keys, tokens, database URLs, whatever the server needs. Encrypted at rest via pgcrypto.

Ajeris spawns the server as a subprocess alongside ajeris-tools on every agent run. The agent sees both tool sets in one unified context.

Example, connecting arvexi-ops:

Label:   Arvexi Ops
Command: node
Args:    /path/to/arvexi/mcp/dist/index.js
Env:     SALESFORCE_USERNAME=..., SLACK_BOT_TOKEN=xoxb-..., etc.

Example, connecting a community GitHub server:

Label:   GitHub
Command: npx
Args:    @modelcontextprotocol/server-github
Env:     GITHUB_PERSONAL_ACCESS_TOKEN=ghp_...

2.2 For remote servers (Streamable HTTP)

The user provides:

  • Label: "My API Tools", "Company Backend"
  • URL: https://my-company.railway.app/mcp
  • Headers: { "Authorization": "Bearer <token>" }

Ajeris connects via the MCP Streamable HTTP transport, standard HTTP POST/GET with JSON-RPC framing, session management via MCP-Session-Id headers.

Example, connecting a remote server:

Label:   Company API
URL:     https://api.mycompany.com/mcp
Headers: { "Authorization": "Bearer sk-live-..." }

2.3 The onboarding UX

NOT in the main onboarding flow. This is a "Power Tools" or "Developer" section in settings, accessible to users who click past the basic "Connect Gmail / Connect Slack" screen. Could be gated behind a toggle.

The flow:

  1. User opens Settings → "Connect Custom Tools"
  2. Chooses transport: "Local Server" or "Remote Server"
  3. Fills in the fields (command/args/env OR url/headers)
  4. Clicks "Test Connection"
  5. Ajeris validates: connects, calls listTools(), shows the user a preview of what tools will be available + how many
  6. User reviews the tool list (security: they see what they're connecting BEFORE it's live)
  7. Clicks "Enable"
  8. Done, next time the agent runs, the custom server is included

For Plan 9 dev (no web UI yet): a CLI script scripts/seed-custom-mcp.ts that takes the same fields and stores them in the DB. Same as how we seed OAuth tokens today.


3. Runtime architecture

3.1 How the agent sees custom servers

The Claude Agent SDK's query() already supports multiple MCP servers via the mcpServers config map:

const stream = query({
  prompt: userMessage,
  options: {
    model,
    systemPrompt,
    mcpServers: {
      // Always present, built-in personal tools
      'ajeris-tools': {
        type: 'stdio',
        command: process.execPath,
        args: [mcpServerEntrypoint],
        env: mcpEnv,
      },
 
      // Dynamically added from user's UserMcpServer rows
      ...customMcpServers,
    },
  },
});

customMcpServers is built at runtime by querying the user_mcp_servers table and constructing the config for each enabled server.

3.2 Building the config from DB rows

async function buildCustomMcpServers(
  prisma: PrismaClient,
  userId: string,
  encryptionKey: string
): Promise<Record<string, McpServerConfig>> {
  const servers = await prisma.userMcpServer.findMany({
    where: { userId, enabled: true },
  });
 
  const result: Record<string, McpServerConfig> = {};
  for (const s of servers) {
    const key = `custom-${s.id.slice(0, 8)}`;
    if (s.transport === 'stdio') {
      result[key] = {
        type: 'stdio',
        command: s.command!,
        args: s.args,
        env: s.envEncrypted
          ? JSON.parse(decrypt(s.envEncrypted, encryptionKey))
          : {},
      };
    } else if (s.transport === 'http') {
      result[key] = {
        type: 'http',
        url: s.url!,
        headers: s.headersEncrypted
          ? JSON.parse(decrypt(s.headersEncrypted, encryptionKey))
          : {},
      };
    }
  }
  return result;
}

3.3 Where this runs in the call chain

User message arrives (SMS or voice)
  → route handler (voice.ts or sms.ts)
    → loadMemories() + loadHistory()     // existing
    → buildCustomMcpServers()            // NEW
    → runAgent({
        ...,
        customMcpServers,                // NEW param
      })
      → query() with merged mcpServers map
        → Claude sees ALL tools from ALL servers
        → picks the right tool based on the user's query

3.4 Connection lifecycle

Stdio servers: spawned as subprocesses at the start of each query() call. Killed when the agent run completes. No persistent connections between turns. This is how ajeris-tools already works.

Streamable HTTP servers: connected at the start of each query() call. Session established via MCP-Session-Id. Session terminated (HTTP DELETE) when the agent run completes. The Claude Agent SDK handles all of this internally via its MCP client implementation.


4. Schema

model UserMcpServer {
  id                String    @id @default(uuid()) @db.Uuid
  userId            String    @map("user_id") @db.Uuid
  label             String
  transport         String    // "stdio" | "http"
  // stdio fields
  command           String?
  args              String[]  @default([])
  envEncrypted      String?   @map("env_encrypted")
  // http fields
  url               String?
  headersEncrypted  String?   @map("headers_encrypted")
  // metadata
  enabled           Boolean   @default(true)
  toolCount         Int?      @map("tool_count")
  toolSchemaHash    String?   @map("tool_schema_hash")
  lastValidatedAt   DateTime? @map("last_validated_at") @db.Timestamptz
  lastErrorMessage  String?   @map("last_error_message")
  createdAt         DateTime  @default(now()) @map("created_at") @db.Timestamptz
  updatedAt         DateTime  @updatedAt @map("updated_at") @db.Timestamptz
 
  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
 
  @@index([userId])
  @@map("user_mcp_servers")
}

Field notes:

  • envEncrypted / headersEncrypted: JSON blobs encrypted via the same pgcrypto system used for OAuth tokens. Raw API keys never sit in plaintext in the DB. Decrypted at runtime only when building the mcpServers config.
  • toolSchemaHash: SHA-256 of the concatenated tool schemas at validation time. Re-checked periodically to detect tool- description mutations (the "rug pull" attack).
  • toolCount: cached count for display and for the tool-budget warning ("this server adds 70 tools to your agent's context").
  • lastErrorMessage: if the server failed on the last validation or last agent run, store the error for debugging. Cleared on successful use.

5. Security model

5.1 Threats and mitigations

ThreatVectorMitigation
Arbitrary code executioncommand in stdio config can be anythingShow exact command to user before enabling; require explicit approval
Tool poisoningMalicious instructions hidden in tool descriptionsShow tool list + descriptions on first connect; hash schemas; alert on change
Rug pullServer changes tool descriptions after approvaltoolSchemaHash compared on every agent run; alert user if hash changes
Credential leakageEnv vars or headers stored in cleartextpgcrypto encryption at rest; never logged; never in LLM context
Cross-server leakageCustom server reads ajeris-tools' env varsSubprocess isolation: each server gets ONLY its own env, not the parent's
SSRF via remote URLUser enters http://169.254.169.254/metadataBlock private IP ranges + link-local + AWS metadata; enforce HTTPS
Runaway tool callsCustom server exposes expensive tools the LLM hammersTool budget cap applies across ALL servers; per-run dollar limit

5.2 Validation flow

When a user connects a new server:

  1. Parse config, validate fields (command exists, URL is HTTPS, no private IPs)
  2. Test connection, actually connect and call listTools()
  3. Present tool list, show the user every tool name + description. They must review before enabling.
  4. Compute schema hash, SHA-256 of the JSON-serialized tool schemas. Store in toolSchemaHash.
  5. Store config, encrypt env/headers, save to DB.
  6. Enable, set enabled: true.

On subsequent agent runs:

  1. Load server config from DB
  2. Decrypt env/headers
  3. Build mcpServers config
  4. (Optional, periodically) re-validate: connect, listTools(), compare schema hash. If changed, disable the server and notify the user.

5.3 Sandboxing (future, not in this commit)

For production, stdio servers should run in containers (Docker, gVisor, or Railway sidecar). This is infrastructure work that belongs in the deployment layer, not the agent code. For Plan 9 dev on a local machine, subprocess isolation (separate env) is sufficient.


6. Tool count awareness

6.1 The problem

Every MCP tool's schema (name, description, input JSON schema) is loaded into the LLM's context on every turn. Rough cost:

  • 1 tool ≈ 150-500 tokens (depends on description length)
  • 10 tools ≈ 2,500 tokens
  • 70 tools (arvexi-ops) ≈ 15,000 tokens
  • 70 + 30 (ajeris-tools) = 100 tools ≈ 20,000+ tokens

At 20k tokens just for tool schemas, you're burning significant context budget before the user's message, memories, history, and system prompt even load. Claude Sonnet 4.6 can handle it (200k context), but it's expensive at scale.

6.2 Mitigations (graduated)

V1 (this commit): Warning only. When a user connects a server, show: "This server adds N tools to your agent. Large tool sets increase response time and cost." No blocking.

V2 (future): Tool filtering. The user can select which tools from a server to enable. Unchecked tools aren't loaded into context.

V3 (future): Dynamic tool loading. The agent first sees a summary of available servers and tool categories, then loads specific server tools only when the user's query is relevant. Requires a two-pass architecture (classify → load → respond).


7. Implementation plan

7.1 Files to create/modify

FileChange
packages/shared/prisma/schema.prismaAdd UserMcpServer model
packages/shared/prisma/migrations/...New migration
packages/shared/prisma/migrations/init_app_role.sqlGrant on new table
packages/shared/src/types.ts(Optional) Export UserMcpServer type
packages/agent/src/custom-mcp.tsNew. buildCustomMcpServers() + validation
packages/agent/src/__tests__/custom-mcp.test.tsNew. Unit tests
packages/agent/src/agent-runner.tsAccept customMcpServers param; merge into mcpServers
packages/agent/src/routes/voice.tsLoad custom servers; pass to runAgent
packages/agent/src/routes/sms.tsSame
packages/agent/src/index.tsForward ENCRYPTION_KEY if not already
scripts/seed-custom-mcp.tsNew. CLI tool for dev: add a custom server
scripts/validate-custom-mcp.tsNew. CLI tool: test-connect a server

7.2 Task breakdown

  1. Schema + migration, Add UserMcpServer model, run migrate, update app role grants. ~15 min.

  2. buildCustomMcpServers(), The core function: query DB, decrypt env/headers, build mcpServers config map. Unit tests with mock Prisma. ~30 min.

  3. Extend runAgent(), Accept optional customMcpServers param, merge into the mcpServers map alongside ajeris-tools. ~10 min.

  4. Wire into routes, Both voice.ts and sms.ts: load custom servers in parallel with memories + history, pass to runAgent. Non-fatal on error (agent runs with built-in tools only). ~20 min.

  5. Seed script, scripts/seed-custom-mcp.ts takes label, transport, command/url, env/headers from CLI args. Encrypts and stores. ~20 min.

  6. Validate script, scripts/validate-custom-mcp.ts connects to a server, lists tools, prints them, computes schema hash. ~20 min.

  7. E2E test, Seed arvexi-ops as a custom server for the test user. Run an agent query that needs an arvexi tool (e.g. "check Sentry for errors"). Verify the tool was called and the response is correct. ~30 min.

  8. Commit, Single cohesive commit with schema + code + scripts

    • tests + this doc updated with implementation status.

Total estimated scope: ~2.5-3 hours.

7.3 Power-user test case

After implementation, A power user seeds their internal MCP server as a custom MCP server:

npx tsx scripts/seed-custom-mcp.ts \
  --label "Arvexi Ops" \
  --transport stdio \
  --command node \
  --args /Users/kamalakanni/Documents/arvexi/mcp/dist/index.js \
  --env-file /Users/kamalakanni/Documents/arvexi/.env.local

Then on voice: "Hey my agent, are there any Sentry errors today?" → Agent reaches for sentry_errors from arvexi-ops MCP server → Voice reads the error summary.

Or: "What's my Salesforce pipeline looking like?" → Agent reaches for sf_get_pipeline → SMS shows pipeline deals with values.


8. Relationship to existing architecture

  • Omnichannel continuity applies to custom tools too. If the agent fetches a Sentry error list on voice, it can push_to_phone the full stack traces instead of reading them aloud.
  • Cross-surface history threads custom-tool queries into the same timeline. "What did I ask about Sentry earlier?" resolves from the conversations table.
  • DeliverySurface inventory is NOT affected, custom MCP servers are tool SOURCES, not delivery surfaces. The agent still delivers via voice/SMS.

9. What's explicitly NOT in this commit

  • Web UI for the settings page (frontend work)
  • Docker sandboxing for stdio servers (infrastructure work)
  • Tool filtering / dynamic loading (V2/V3, future)
  • MCP Registry integration / marketplace browsing (Phase 3)
  • OAuth flow for remote servers (not needed for Bearer token auth)
  • Multi-workspace / multi-tenant server isolation (production hardening, separate from the feature itself)

10. Success criteria

  • UserMcpServer model in Prisma, migration applied
  • buildCustomMcpServers() correctly constructs mcpServers config from DB rows, with env/headers decrypted
  • runAgent() merges custom servers alongside ajeris-tools
  • Both voice and SMS routes load custom servers and pass through
  • Seed script successfully stores arvexi-ops config in DB
  • Validate script connects to arvexi-ops, lists 70+ tools, computes schema hash
  • E2E: agent query using an arvexi-ops tool produces correct response on both voice and SMS
  • Failure mode: disabled/broken custom server doesn't crash the agent, it falls back to built-in tools only
  • All existing 363+ tests still pass (no regressions)