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.mdPhase 1 (shipped, Slack tools) Supersedes: Phase 2 section inwork-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:
- User opens Settings → "Connect Custom Tools"
- Chooses transport: "Local Server" or "Remote Server"
- Fills in the fields (command/args/env OR url/headers)
- Clicks "Test Connection"
- Ajeris validates: connects, calls
listTools(), shows the user a preview of what tools will be available + how many - User reviews the tool list (security: they see what they're connecting BEFORE it's live)
- Clicks "Enable"
- 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
| Threat | Vector | Mitigation |
|---|---|---|
| Arbitrary code execution | command in stdio config can be anything | Show exact command to user before enabling; require explicit approval |
| Tool poisoning | Malicious instructions hidden in tool descriptions | Show tool list + descriptions on first connect; hash schemas; alert on change |
| Rug pull | Server changes tool descriptions after approval | toolSchemaHash compared on every agent run; alert user if hash changes |
| Credential leakage | Env vars or headers stored in cleartext | pgcrypto encryption at rest; never logged; never in LLM context |
| Cross-server leakage | Custom server reads ajeris-tools' env vars | Subprocess isolation: each server gets ONLY its own env, not the parent's |
| SSRF via remote URL | User enters http://169.254.169.254/metadata | Block private IP ranges + link-local + AWS metadata; enforce HTTPS |
| Runaway tool calls | Custom server exposes expensive tools the LLM hammers | Tool budget cap applies across ALL servers; per-run dollar limit |
5.2 Validation flow
When a user connects a new server:
- Parse config, validate fields (command exists, URL is HTTPS, no private IPs)
- Test connection, actually connect and call
listTools() - Present tool list, show the user every tool name + description. They must review before enabling.
- Compute schema hash, SHA-256 of the JSON-serialized tool
schemas. Store in
toolSchemaHash. - Store config, encrypt env/headers, save to DB.
- Enable, set
enabled: true.
On subsequent agent runs:
- Load server config from DB
- Decrypt env/headers
- Build mcpServers config
- (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
| File | Change |
|---|---|
packages/shared/prisma/schema.prisma | Add UserMcpServer model |
packages/shared/prisma/migrations/... | New migration |
packages/shared/prisma/migrations/init_app_role.sql | Grant on new table |
packages/shared/src/types.ts | (Optional) Export UserMcpServer type |
packages/agent/src/custom-mcp.ts | New. buildCustomMcpServers() + validation |
packages/agent/src/__tests__/custom-mcp.test.ts | New. Unit tests |
packages/agent/src/agent-runner.ts | Accept customMcpServers param; merge into mcpServers |
packages/agent/src/routes/voice.ts | Load custom servers; pass to runAgent |
packages/agent/src/routes/sms.ts | Same |
packages/agent/src/index.ts | Forward ENCRYPTION_KEY if not already |
scripts/seed-custom-mcp.ts | New. CLI tool for dev: add a custom server |
scripts/validate-custom-mcp.ts | New. CLI tool: test-connect a server |
7.2 Task breakdown
-
Schema + migration, Add UserMcpServer model, run migrate, update app role grants. ~15 min.
-
buildCustomMcpServers(), The core function: query DB, decrypt env/headers, build mcpServers config map. Unit tests with mock Prisma. ~30 min.
-
Extend runAgent(), Accept optional
customMcpServersparam, merge into the mcpServers map alongside ajeris-tools. ~10 min. -
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.
-
Seed script,
scripts/seed-custom-mcp.tstakes label, transport, command/url, env/headers from CLI args. Encrypts and stores. ~20 min. -
Validate script,
scripts/validate-custom-mcp.tsconnects to a server, lists tools, prints them, computes schema hash. ~20 min. -
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.
-
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.localThen 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_phonethe 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
-
UserMcpServermodel 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)