MCP Manager
When to use
- Adding a new tool (any domain or a new one)
- Reviewing a PR that touches
apps/mcp-server/ - Debugging 401s, RPC errors, or transport failures
- Changing OAuth flow or transport architecture
Layout
apps/mcp-server/src/
main.ts # entry: bootHttp | bootStdio
types.ts # ok() fail() paginated() zUuid
config.ts # getConfig() → McpServerConfig
middleware/auth.ts # resolveAuth() → AuthContext
transport/http.ts # HTTP + OAuth + session map
transport/stdio.ts # stdio (service-role, no auth)
tools/
lens/ # lens-*.ts + index.ts → registerLensTools()
battle/ # battle-*.ts + index.ts → registerBattleTools()
workflow/ # workflow-*.ts + index.ts → registerWorkflowTools()
Naming rule
Two separate conventions — keep them consistent:
| Artifact | Convention | Example |
|---|---|---|
| File stem | <domain>-<action> kebab |
lens-get-version.ts |
| MCP tool name | verb_noun verb-first snake_case |
get_lens_version |
| Export function | register<Domain><Action> PascalCase |
registerLensGetVersion |
| Supabase RPC | fn_mcp_<domain>_<action> domain-first |
fn_mcp_lens_get_version |
MCP tool names are verb-first so AI agents can group by action (get_*, list_*).
File stems are domain-first so tools sort together on disk.
Verb prefix in use: get_* list_* create_* update_* delete_* run_* search_* set_* archive_* fork_* submit_* retry_* summarize_* find_and_run_* validate_* extract_* add_*
Multi-word actions: first action-word becomes the verb, domain slots in second.
battle-submit-run.ts → tool submit_battle_run, RPC fn_mcp_battle_submit_run
Tool template
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SupabaseClient } from '@supabase/supabase-js';
import { ok, fail, zUuid } from '../../types.js';
export function registerLensPin(server: McpServer, sb: SupabaseClient): void {
server.tool(
'pin_lens', // verb_noun — verb first
'Pin a lens to the caller's profile.',
{ lens_id: zUuid },
async ({ lens_id }) => {
const t0 = Date.now();
try {
const { data, error } = (await sb.rpc('fn_mcp_lens_pin' as never, { // fn_mcp_domain_action
p_lens_id: lens_id,
})) as unknown as { data: unknown; error: { message: string } | null };
if (error) throw new Error(error.message);
if (!data) return fail('NOT_FOUND', `Lens ${lens_id} not found`, {}, 'pin_lens', t0);
return ok(data, 'pin_lens', t0);
} catch (e) {
return fail('DB_ERROR', (e as Error).message, {}, 'pin_lens', t0);
}
}
);
}
Register it
// tools/lens/index.ts — add one import + one call
import { registerLensPin } from './lens-pin.js';
export function registerLensTools(server, sb) {
// ...existing...
registerLensPin(server, sb);
}
main.ts already calls registerLensTools — no changes needed there.
Creation checklist
- File:
tools/<domain>/<domain>-<action>.ts - Tool name:
verb_nounverb-first (e.g.pin_lens,submit_battle_run) - Export:
register<Domain><Action>PascalCase words from file stem - RPC:
fn_mcp_<domain>_<action>domain-first, mirrors file stem - UUID params use
zUuid(notz.string().uuid()) -
const t0 = Date.now()beforetry - RPC cast:
as never+ explicit result type -
if (error) throw new Error(error.message)after each RPC -
fail('NOT_FOUND', ...)for null row;fail('DB_ERROR', ...)in catch - No
getServiceClient()inside the handler - DB access via
fn_mcp_*RPC only — never.schema(),.from(), or any query builder - Registered in
tools/<domain>/index.ts - Docs updated:
docs/en/reference/mcp-server/tools-<domain>.md
Scripts
bash scripts/new-tool.sh lens pin # → tools/lens/lens-pin.ts, tool: pin_lens
bash scripts/new-tool.sh lens get-version # → tools/lens/lens-get-version.ts, tool: get_lens_version
bash scripts/validate-tool.sh <file.ts> # check conventions
Reference
- REFERENCE.md — transport, auth, helpers, error codes, debug
- PR checklist — full PR review checklist