SocialKit ships two front doors: an authed JSON REST API and an MCP server for agents. The fastest way to make those two surfaces lie to each other is to implement scoring once for REST and once for MCP. So we don't.
Cores know nothing about transport
Each capability, score, rewrite, generate, plan, build_voice, is a pure function. It takes a fully resolved input and the gateway token, and returns a typed result. It does not know whether a request arrived as HTTP or JSON-RPC. It does not touch the database.
// The core is transport-free and db-free.
export async function generate(
input: GenerateInput,
ctx: { gatewayToken: string; brand?: BrandContext; voice?: VoiceProfile },
): Promise<GenerateResult> { /* ... */ }The adapters do the boring work that differs per transport. The REST handler parses and validates the body, resolves a brand_id and voice_id into the actual objects, and calls the core. The MCP tool does the same resolution from its arguments and calls the same core. Two thin adapters, one implementation.
Why it's worth the discipline
When we added account-scoped voices, REST and MCP both got them the moment the core did. There was no second wiring pass, no chance for the MCP path to fall behind. The contract test asserts the OpenAPI spec matches the real surface, and the same cores back the spec. Drift has nowhere to hide.
It's not a clever pattern. It's just refusing to write the same capability twice. For a product whose whole pitch is one engine behind many surfaces, that refusal is the product.