← All posts

One capability core, two transports

REST and MCP expose the same SocialKit capabilities. We made sure a capability is never implemented twice. Here's the pattern.

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.

Score a post against all of this.

PostScore is free and needs no signup. Paste a draft, see the breakdown.

Score a post