The standard way to give an agent access to three systems is to run three MCP servers. The host config lists a Trino server, a DataHub server, and an S3 server, each its own process, each its own transport, each its own auth. It works, and for a single backend it is the right shape. The moment you want a Trino result to arrive already carrying its DataHub ownership and PII tags, the fleet model fails, because three processes that only meet inside the host model have no way to enrich each other. mcp-data-platform takes the other path: it pulls the sister servers in as Go libraries and runs them as toolkits inside one process. This post is about how that composition works and what it costs.
This is the second post in MCP by Design. The first argued for Go as the language that lets you constrain both the agent writing an MCP server and the agent calling it. This one is the first design decision that follows: one process instead of many. It is grounded in the open-source
txn2/mcp-data-platform, also available hosted as Plexara. Code here is read off the current source, spec revision2025-11-25, Go SDKv1.6.1.
§The Default Is a Fleet of Processes
The stdio transport post showed the usual deployment: an MCP server is a subprocess the host spawns and talks to over pipes. Add a second backend and you add a second subprocess. Three backends, three entries in the host config, three handshakes, three tool lists the host stitches together for the model.
Nothing is wrong with that until the backends need to know about each other. The mcp-trino, mcp-datahub, and mcp-s3 servers I wrote each stand alone, and each is the right tool for its own job. But a Trino server cannot annotate a query result with the table’s owners and deprecation status, because owners and deprecation live in DataHub, and the Trino process has never heard of DataHub. The host could ask both and try to correlate, but the host is a model, and asking a model to reliably join two tool outputs by hand is the thing you are trying to avoid. The enrichment has to happen somewhere that can see both systems at once. The cheapest such place is one process.
§A Toolkit Is an Adapter, Not a Reimplementation
The composition rests on one interface. Every backend the platform speaks to implements Toolkit:
// Toolkit is the interface that all composable toolkits must implement.
type Toolkit interface {
Kind() string // "trino", "datahub", "s3"
Name() string // instance name from config
Connection() string // backend identity for audit logging
RegisterTools(s *mcp.Server) // register tools with the MCP server
Tools() []string // the tool names this toolkit provides
SetSemanticProvider(provider semantic.Provider) // for enrichment
SetQueryProvider(provider query.Provider) // for enrichment
Close() error
}
The part that matters for composition is that a toolkit does not reimplement its backend. It wraps the sister server’s own library. The platform’s Trino toolkit imports mcp-trino directly and builds the real thing:
import (
trinoclient "github.com/txn2/mcp-trino/pkg/client"
trinotools "github.com/txn2/mcp-trino/pkg/tools"
)
func createToolkit(client *trinoclient.Client, cfg Config, elicit *ElicitationMiddleware) *trinotools.Toolkit {
opts := buildToolkitOptions(cfg, elicit, nil)
return trinotools.NewToolkit(client, trinotools.Config{
DefaultLimit: cfg.DefaultLimit,
MaxLimit: cfg.MaxLimit,
}, opts...)
}
trinoclient.New is the same client mcp-trino uses when it runs standalone, and trinotools.NewToolkit is the same tool set. The platform’s Trino package is a thin adapter: it parses platform config, constructs the upstream client and tools, and presents them through the Toolkit interface. mcp-s3 and mcp-datahub are wrapped the same way. Bug fixes and new tools in the sister repos arrive by bumping a version in one go.mod, not by porting code. The sister servers are libraries that happen to also ship a main.
§The Registry Wires Kinds to Factories
Toolkits are not constructed by hand. A registry maps each kind to a factory, and the built-in set is one short function:
func RegisterBuiltinFactories(r *Registry) {
r.RegisterAggregateFactory("trino", TrinoAggregateFactory)
r.RegisterFactory("datahub", DataHubFactory)
r.RegisterFactory("s3", S3Factory)
r.RegisterAggregateFactory(gatewaykit.Kind, GatewayAggregateFactory)
r.RegisterAggregateFactory(apigatewaykit.Kind, APIGatewayAggregateFactory)
}
A factory takes a name and a config map and returns a Toolkit. The platform reads its YAML, and for each configured backend it calls CreateAndRegister, which looks up the factory by kind and builds the instance. The whole composition is data: add a Trino block to the config and a Trino toolkit appears in the process; the model sees its tools in the next tools/list. The single-server skeleton from the MCP on the Wire capstone is still in there, one mcp.Server with tools registered against it. The registry just registers many toolkits’ tools against that one server instead of one.
§Why Some Kinds Are “Aggregate” Factories
Two of those registrations say RegisterFactory and three say RegisterAggregateFactory. The difference is a design lesson I paid for.
The obvious way to support two Trino clusters is to build two Trino toolkits, each with its own client. That breaks, and the factory comment records why:
// TrinoAggregateFactory creates a single multi-connection Trino toolkit
// from all configured instances. This ensures deterministic connection
// routing based on the "connection" parameter in each tool call, rather
// than the non-deterministic last-write-wins behavior of N separate toolkits.
Two toolkits of the same kind both register a tool named trino_query. The second registration clobbers the first, and which one wins depends on map iteration order, so the agent’s trino_query calls land on whichever cluster the runtime happened to register last. The fix was to collapse same-kind instances into one toolkit that owns trino_query once and routes on a connection argument in the call. That is what an aggregate factory is: one toolkit, many backends, deterministic routing. Single-instance kinds like DataHub and S3 stay simple factories; the kinds that fan out, Trino and both gateways, are aggregates. The general rule fell out of the bug: if two instances of a kind would register the same tool name, they must be one toolkit.
The gateway factories carry a second piece of hard-won behavior. They absorb per-connection failures at startup instead of propagating them, so one unreachable upstream cannot stop the whole platform from booting:
// Per-instance config parse errors are logged and skipped ... so a
// misconfigured or unreachable upstream cannot block platform startup.
In a fleet of separate processes you get that isolation for free; one bad server crashes alone. Once everything shares a process, fail-soft startup is something you have to build on purpose.
§The Shared Substrate
Here is the payoff that the whole composition exists to enable. The registry holds two providers, a semantic.Provider and a query.Provider, and it pushes them into every toolkit it manages:
func (r *Registry) SetSemanticProvider(provider semantic.Provider) {
r.mu.Lock()
defer r.mu.Unlock()
r.semanticProvider = provider
for _, toolkit := range r.toolkits {
toolkit.SetSemanticProvider(provider)
}
}
The semantic provider is backed by DataHub. The query provider is backed by Trino. Once they are injected, every toolkit in the process holds a handle to the metadata catalog and the query engine, regardless of which backend that toolkit fronts. The S3 toolkit can ask the semantic provider whether an object matches a catalogued dataset. The DataHub toolkit can ask the query provider whether a searched table is queryable and what the SQL would be. None of it crosses a network boundary or a process boundary, because all of it lives in the same address space. That shared substrate is what makes cross-enrichment possible, and it is the subject of the next post, where the enrichment actually gets spliced into tool results in middleware.
§What You Give Up
Composition is not free, and pretending otherwise would be the same overstatement this series exists to argue against.
You give up process isolation. Three servers in three processes fail independently; a panic in one cannot take the others down, and a memory leak is contained. One process means one blast radius. The platform answers this with fail-soft factories and bounded memory budgets, but the structural fact remains: you traded isolation for shared context, and you have to earn back the safety isolation gave you for free.
You give up independent dependency trees. The sister servers now share one go.mod. The current pins are mcp-trino v1.3.1, mcp-s3 v1.3.0, and mcp-datahub v1.9.0, and they must agree on every transitive dependency, including the MCP SDK version itself. A breaking change in one sister’s dependencies is now your problem to resolve before you can upgrade any of them. In exchange, every toolkit speaks the exact same SDK, so a frame that leaves one toolkit and a frame that leaves another are byte-for-byte the same protocol, which is worth a great deal when you are debugging on the wire.
The trade is worth making when the backends need to enrich each other, which for a data platform is the entire point. If your server fronts one system and will only ever front one, run it standalone; the fleet model is simpler and isolation is real. Compose when the value is in the join, and the join is cheaper to do in one process than to ask a model to do by hand.
The next post takes the shared substrate this composition created and shows the middleware stack that uses it: enriching results, steering agents toward discovery before they query, and returning errors a model can actually recover from.
The platform behind this series is txn2/mcp-data-platform, available hosted as Plexara.