MCP
Referencia · para quien construye MCPs

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:

.npmrc
@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:

yaml
- 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).

SubpathQué trae
./toolsdefineTool, ToolDef, ToolResult, textResult, jsonResult, throwIfError, registerTools.
./errorscreateErrorTranslator — la máquina de traducción; el diccionario queda en cada dominio.
./capscreateScopeCaps — convención scope→capabilities genérica.
./authzcreateAuthzCache, failClosedAuthz — caché de autorización con fail-closed.
./identityPipeline bearer completo: JWT (JWKS + round-trip HS256), token opaco, env-list, orden de resolución.
./httpcreateStreamableHttpHandler — ciclo per-request stateless (401/405/503 congelados).
./stdiorunStdioServer.
./sessionPersistencia de sesión atómica + attachSessionPersistence (rotación de refresh token).
./supabasecreateSupabaseClient, hydrateSession.
./loginrunLoginFlow (password + OTP).
./envcreateEnvResolver, envLabel (marca PROD ⚠️).
./versiongetVersion — single-source desde el package.json del consumidor.

4. Puntos de extensión — el core nunca conoce el dominio

SeamMecanismoDefault 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 JOSEAlgNotAllowednull 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):

  1. Crear el paquete de dominio consumiendo los subpaths que necesita — típicamente ./tools (con defineTool), ./errors (con su propio diccionario de códigos), y si el dominio necesita sesión de usuario, ./supabase + ./session + ./login.
  2. Definir las tools del dominio con defineTool(ctx, name, description, shape, run, opts), pasando su propio traductor de errores en opts.translateError.
  3. Registrar las tools en un McpServer del SDK con registerTools(server, tools).
  4. Elegir transporte: runStdioServer(server) para uso local, o createStreamableHttpHandler({...}) si el dominio necesita un servidor remoto (como ops) — en ese caso el dominio aporta isDisabled, resolveIdentity y buildServer.
  5. Un futuro gateway HTTP (packages/mcp-gateway, reservado, no implementado aún) podría componer varios dominios (ops + analytics + futuros) detrás de un único createIdentityResolver, 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

WorkflowDispara conQué 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.