mcp-core, el contrato compartido
Esta página es para quien mantiene o extiende un MCP de Dynamo —
no hace falta leerla para usar ops o
dynamo-analytics día a día (ver
MCP Ops /
MCP Analytics).
1. Qué es
@dynamotechtvt/mcp-core es el paquete que vive en el
monorepo mcp-dynamo (packages/mcp-core) y
concentra lo que todos los MCP de dominio de
Dynamo necesitan: definición de tools, traducción de errores,
resolución de identidad bearer (JWT / token opaco / listas de
env), autorización con caché, los dos transportes
(stdio y HTTP streamable stateless), y la
capa de sesión/login de Supabase.
Restricción rectora del diseño: la adopción por
ops-dynamo y analytics-dynamo es
sin cambio de comportamiento observable — mismos
códigos HTTP, mismos bodies literales, mismo orden de resolución de
identidad, mismos mensajes de error en español. Hoy
ya está adoptado por los dos: ops-dynamo
lo usa en ui/mcp-http.mjs (identidad/authz/HTTP) y
analytics-dynamo en mcp/src/tools/types.ts
y afines (tools/errores/sesión).
2. Consumirlo desde GitHub Packages
El paquete se publica al registry de GitHub Packages bajo el
scope @dynamotechtvt. En el repo consumidor, un
.npmrc (o la config equivalente de tu CI) con:
@dynamotechtvt:registry=https://npm.pkg.github.com
y el token de autenticación vía variable de entorno
(NODE_AUTH_TOKEN), como en ci.yml de este
repo:
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://npm.pkg.github.com'
scope: '@dynamotechtvt'
- name: Install dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Gotcha conocido — 403 en npm ci.
La organización DynamoTechTVT es un plan
Team (no Enterprise): un package de GitHub
Packages no es automáticamente legible por todo repo de la org.
Cada repo consumidor nuevo necesita un grant
Read explícito en la configuración del package
(Package settings → Manage Actions access → agregar el repo con
rol Read), o su npm ci/npm install
falla con 403. Mismo patrón documentado para
@dynamotechtvt/auth — si tu CI da 403 al instalar
@dynamotechtvt/mcp-core, empezá por acá.
3. Subpaths exportados
Sin barrel raíz — solo subpaths, para que cada consumidor cargue
únicamente lo que usa (ops no tiene supabase-js,
por ejemplo, y no debe arrastrar ./supabase).
| Subpath | Qué trae |
|---|---|
./tools | defineTool, ToolDef, ToolResult, textResult, jsonResult, throwIfError, registerTools. |
./errors | createErrorTranslator — la máquina de traducción; el diccionario queda en cada dominio. |
./caps | createScopeCaps — convención scope→capabilities genérica. |
./authz | createAuthzCache, failClosedAuthz — caché de autorización con fail-closed. |
./identity | Pipeline bearer completo: JWT (JWKS + round-trip HS256), token opaco, env-list, orden de resolución. |
./http | createStreamableHttpHandler — ciclo per-request stateless (401/405/503 congelados). |
./stdio | runStdioServer. |
./session | Persistencia de sesión atómica + attachSessionPersistence (rotación de refresh token). |
./supabase | createSupabaseClient, hydrateSession. |
./login | runLoginFlow (password + OTP). |
./env | createEnvResolver, envLabel (marca PROD ⚠️). |
./version | getVersion — single-source desde el package.json del consumidor. |
4. Puntos de extensión — el core nunca conoce el dominio
| Seam | Mecanismo | Default fail-closed |
|---|---|---|
| Authz de dominio | createAuthzCache({ fetch, nullUserFallback }) — el dominio pasa el RPC/query propio. |
Excepción del fetch → failClosedAuthz(), nunca se cachea. |
| Registry de errores | createErrorTranslator({ knownErrors, dynamicTranslators, fallbackPrefix }) por dominio. |
Error no reconocido → ${fallbackPrefix}${raw}, nunca lanza. |
| Tokens opacos | resolveOpaqueToken inyectado en createIdentityResolver; el schema de la tabla es del dominio. |
Sin match → sigue el pipeline (env-list) → null → 401. |
| Caps | Caps = Record<string, boolean> + createScopeCaps(tabla) del dominio. |
Scope desconocido → null, el dominio decide. |
| Contexto de ejecución | runInContext en el handler HTTP (ej. RLS por identidad de máquina). |
Default: fn() sin envolver. |
| Disabled check | createDisabledCheck({ envLists, hasActiveDbToken }). |
Env-lists vacías + hasActiveDbToken false o throw → 503. |
| JWT | createJwtVerifier parametriza URL/anon key por dominio. |
Todo fallo salvo JOSEAlgNotAllowed → null terminal. |
| Gateway futuro | Cada dominio exporta ToolDef[]; el gateway compone con registerTools. |
— |
5. Cómo nace un MCP de dominio nuevo
El plan (docs/plans/mcp-core.md § Fase 3, futura)
define el camino para un dominio nuevo (por ejemplo
auth-mcp o forms-mcp):
-
Crear el paquete de dominio consumiendo los subpaths que
necesita — típicamente
./tools(condefineTool),./errors(con su propio diccionario de códigos), y si el dominio necesita sesión de usuario,./supabase+./session+./login. -
Definir las tools del dominio con
defineTool(ctx, name, description, shape, run, opts), pasando su propio traductor de errores enopts.translateError. -
Registrar las tools en un
McpServerdel SDK conregisterTools(server, tools). -
Elegir transporte:
runStdioServer(server)para uso local, ocreateStreamableHttpHandler({...})si el dominio necesita un servidor remoto (comoops) — en ese caso el dominio aportaisDisabled,resolveIdentityybuildServer. -
Un futuro gateway HTTP
(
packages/mcp-gateway, reservado, no implementado aún) podría componer varios dominios (ops+analytics+ futuros) detrás de un únicocreateIdentityResolver, registrando[...opsTools(ctx), ...analyticsTools(ctx)]según la identidad resuelta — sin cambios de API del core.
6. Doctrina — literales congelados (R5)
Todo string que hoy ve un cliente del MCP (bodies
401/405/503, el mensaje
Input inválido para "…", los reasons de
hydrateSession, los labels de envLabel)
vive verbatim en el core. Cambiar cualquiera de
esos literales — aunque "mejore" el wording — es un
breaking change semver-major. El candado es
message-freeze.test.ts: cualquier PR que lo toque
necesita justificación explícita y bump de versión mayor.
7. CI/CD de este repo
| Workflow | Dispara con | Qué corre |
|---|---|---|
ci.yml |
PR hacia qa o main |
Type check (tsc --noEmit) + build + test de todos los workspaces. Gate: bloquea el merge si falla. |
publish.yml |
Push de un tag v* |
Type check + build + test (incluye message-freeze.test.ts) → npm publish --workspace packages/mcp-core a GitHub Packages. Sin suite verde no se publica. |
Flujo de ramas igual al resto de repos Dynamo:
feature/* → PR → qa → PR → main. Un agente puede
mergear feature→qa; qa→main es
siempre decisión de Daniel.