Architecture
Audience: contributors. This describes the library's internals and toolchain; consumers only need the guides and reference sections.
Executive Summary
pascal-mcp-sdk is six units layered strictly bottom-up: MCP.JSONRPC
(the JSON-RPC 2.0 profile MCP mandates), MCP.Protocol (the stateless
per-request _meta model of spec revision 2026-07-28), MCP.Schema
(tool schemas as Pascal — fluent builder and RTTI-derived argument
classes), MCP.Server (the sans-I/O dispatch core holding frozen
tool/resource registries plus per-connection sessions), and two
transport shells: MCP.Transport.Stdio (the newline-delimited stdio
binding) and MCP.Transport.HTTP (Streamable HTTP). The core performs
no I/O — CreateSession binds connection state and HandleMessage
maps one inbound line to at most one response line — so both bindings
wrap the same tested core without touching it. The runtime dependency
set is FPC's RTL + fpjson (plus fcl-web, shipped inside FPC, confined
to the HTTP transport unit), nothing else.
Layering
MCP.Transport.Stdio MCP.Transport.HTTP
lines in/out, LF framing POST + SSE, header mirroring, statuses
│ │
└────────────┬───────────┘
MCP.Server ◄── MCP.Schema frozen core + session; HandleMessage(session, line) → line
│ (fluent builder + RTTI argument classes; consumed by
│ MCP.Server, off the linear request chain)
MCP.Protocol _meta validation, version negotiation, result stamping
│
MCP.JSONRPC JSON-RPC 2.0 parse/build, MCP profile + error codes
│
RTL + fpjson the only runtime dependenciesThe strict bottom-up request chain is MCP.JSONRPC → MCP.Protocol →
MCP.Server → transport. MCP.Schema sits to the side of that chain: a
helper MCP.Server consumes to build and enforce tool schemas, not a
layer a request passes through.
Rules live in the layer that owns them and nowhere else:
MCP.JSONRPC— message classification (request / notification / invalid), MCP's tightened id rules (string or number, never null), batch rejection, params-must-be-object, id preservation into error replies, compact single-line serialization. Knows nothing about MCP methods.MCP.Protocol— the reserved_metakeys, the two required per-request fields (protocolVersion,clientCapabilities),-32602for their absence,-32022(+supportedlist) for version mismatch, and the response-side stamping (resultType: "complete",serverInfo). Knows nothing about tools or resources.MCP.Server— method dispatch (server/discover,tools/*,resources/*,prompts/*), frozen registries and request-visible configuration, per-connection legacy state, cooperative cancellation, in-band vs. protocol error policy (handler exceptions →isError: trueresults; dispatch faults → JSON-RPC errors), the legacy-initializerejection that names supported versions. Knows nothing about bytes or streams.MCP.Schema— the tool-schema helperMCP.Serverconsumes, not a link in the request chain: the fluent builder (ObjectSchema.AddString(...)) and the RTTI-derivedTMCPArgsargument classes, both emitting the JSON Schema 2020-12 subset the server enforces. Knows nothing about dispatch, bytes, or streams.MCP.Transport.Stdio— LF-terminated writes on every platform, CR tolerance on reads, blank-line skipping, stderr-only logging, EOF as the graceful-shutdown signal. Contains not a single protocol decision.
The sans-I/O core
TMCPServer.CreateSession creates state bound to that server, and
TMCPServer.HandleMessage(ASession, const ALine; out AResponse): Boolean
is the line-oriented protocol surface. Unit tests drive both directly
(no pipes, no processes); RunMCPStdioLoop creates one session for its
connection and passes it on every call; MCP.Transport.HTTP does the
same per HTTP request. This mirrors
duetto's WS.Protocol discipline: one tested core, transports as
delivery. Passing nil or a session from another server is API misuse and
raises EMCPServer; malformed wire input is still converted to JSON-RPC
errors.
The one addition to line-in/line-out is the per-call notification
sink: the sink and its user data are arguments to the HandleMessage
overload, so neither shared server state nor a session retains transport
state. Handlers can emit request-scoped notifications
(MCPReportProgress, MCPLogMessage) that the active transport writes
before the response — exactly the stream the spec describes for both
stdio and Streamable HTTP (where the same sink becomes SSE events on the
POST response). Emission is strictly opt-in per request
(_meta.progressToken for progress; the logLevel key for log messages,
severity-filtered per RFC 5424) and both helpers are no-ops without a
sink, so the core stays testable without I/O.
Modern 2026-07-28 requests remain stateless: every request validates
its own _meta, and the session contributes no negotiated identity to
that path. The session exists to isolate legacy lifecycle/identity and
to expose the one currently active cooperative-cancellation token.
notifications/cancelled validates its payload, matches string ids by
decoded value and numeric ids by value, and flips that token; handlers
poll TMCPRequestContext.IsCancelled, after which the server suppresses
notifications and the final response. The synchronous stdio
read-handle-write loop cannot receive a cancellation while a handler is
running, so stdio handlers should stay short; a future transport with
mid-request delivery points can use the same session entry point.
Registration and handler model
Registries and request-visible configuration (Instructions, cache
policy, error redaction, and dual-era mode) are populated at startup and
freeze when the first session is created. That is why no
listChanged/subscribe capability is advertised and
subscriptions/listen is out of v1. Handlers are synchronous and come
in two shapes per registry — plain function pointers and of object
method pointers — so both programs and class-based hosts (lantaarn)
register naturally. Tool schemas come from MCP.Schema in two forms:
the fluent builder (ObjectSchema.AddString(...)... — a JSON Schema
2020-12 subset covering the flat object schemas most tools need, with
input and output schema overloads) and argument classes —
TMCPArgs descendants whose published properties expand into the
schema via RTTI (SchemaFrom), with the server binding, validating,
and populating a typed instance per call (missing/mistyped arguments
become in-band isError results before the handler runs; classes
rather than records because FPC 3.2.2 RTTI has no record field names).
Richer schemas use the JSON-string or definition-object overloads,
parsed for well-formedness at registration (EMCPServer on error).
MRTR (Multi Round-Trip Requests, SEP-2322; #4) is the 2026-07-28
replacement for server-initiated requests: a tools/call or
prompts/get handler that needs more input returns
MCPInputRequired(...) / MCPPromptInputRequired(...) — an
input_required result carrying an inputRequests map (entry
builders for elicitation form/url, sampling/createMessage,
roots/list) and the handler's opaque requestState. The client
retries the original request with inputResponses plus the echoed
state, and the library re-enters the same handler with both exposed
on TMCPRequestContext — each round is one ordinary HandleMessage
call, so the server stays stateless across rounds. Kinds are gated on
the per-request client capabilities (-32021 when undeclared), and
the whole mechanism is modern-era only. Sampling and roots are
deprecated in the final spec (SEP-2577) but carried deliberately —
each kind is one builder plus one accessor, so a later sunset is
cheap. Proven against the official client's auto-fulfilment driver in
all three interop batteries and by mcpsmoke.
Since the HTTP era inverted the trust boundary (#23), every raw-handler
tool call is validated against its registered schema's enforceable
subset before the handler runs — type, description, title,
properties, required, enum, default, exactly the dialect the
builders emit (the fluent builder stamps non-empty descriptions, which
the server accepts without .ApplicationValidated) — with
violations returned as in-band isError results, the same shape the
typed path produces. Absent optional arguments are seeded with their
schema default; unknown argument properties are ignored (the same
tolerance the typed path applies to unknown keys). A raw schema using
keywords outside the subset fails at freeze unless the registration is
marked .ApplicationValidated — the documented escape hatch that hands
argument validation back to the handler. Deeper, semantic validation
remains the handler's job, reported as in-band isError results that a
model can read and correct against.
Spec grounding
Verified 2026-07-20 against the RC pages; re-verified 2026-08-08 against the published final text (modelcontextprotocol.io):
- The current ratified revision is
2026-07-28— final shipped July 28, 2026 (versioning). It removes theinitializehandshake, protocol-level sessions, theMcp-Session-Idheader, and the GET SSE stream; server→client requests are replaced by Multi Round-Trip Requests. - This library implements
2026-07-28from the pinned final pages: transports overview, stdio binding,_meta+ error codes, versioning, server/discover, tools, resources. - The 2026-08-08 re-verification diffed the
final changelog
against the RC surface this library implements and re-ran the interop
batteries on the stable SDKs (
@modelcontextprotocol/client2.0.0,@modelcontextprotocol/sdk1.30.0): no drift on the implemented surface — the RC facts the library absorbed (top-levelserverInfo, requiredttlMs/cacheScope,-32020..-32022error codes, resource-not-found-32602, per-requestlogLevelgating) all appear unchanged in the final text. - The prose pages are not the whole truth — the schema anchor is.
Interop against the official TypeScript client (via
tools/interop-ts/) surfaced two requirements the prose pages underplay:DiscoverResultrequires a top-levelserverInfofield (the_metastamp alone reads as a legacy server to the probe), andttlMs+cacheScope(SEP-2549 CacheableResult) are required on discover/list/read results. Both are implemented and pinned by unit tests,mcpsmoke, and the interop battery. Protocol claims should be checked against the schema and a real client implementation, not prose alone.
pascal-mcp-sdk is a dual-era server (spec's compatibility matrix, on
by default): era selection follows how the client opens. A request
carrying the modern per-request _meta protocol-version key is served
statelessly per 2026-07-28; an initialize request selects legacy
semantics for 2024-11-05, 2025-06-18, and 2025-11-25, scoped to
the connection's TMCPSession — the deliberate cross-request state the
compatibility model prescribes, isolated even when sessions share one
server core. 2025-03-26
is excluded because its Base Protocol requires receivers to accept
JSON-RPC batches, which this library does not implement
(Base Protocol,
verified 2026-07-20). Both eras run concurrently on the same instance;
handlers are era-blind (TMCPRequestContext is filled from _meta or
from the stored handshake). The legacy dialect is era-faithful at the
edges: no resultType/serverInfo stamps, no SEP-2549 cache fields,
resource-not-found -32002, and ping answered. DualEra := False
restores strict modern-only behavior (initialize rejected with a
diagnostic naming supported versions, as the spec recommends). Proven
end-to-end by tools/interop-ts: the stable v2 client negotiates modern
(auto-probe included), the v1 SDK client (Claude Code's library)
completes the classic handshake, and Claude Code itself connects via
claude mcp add.
The HTTP binding
MCP.Transport.HTTP is the second transport shell (Streamable HTTP,
2026-07-28 profile): every JSON-RPC message is its own POST to a
single /mcp endpoint, notifications answer 202, and requests that
opt into request-scoped notifications (_meta.progressToken /
logLevel) on handler-backed methods are answered as SSE streams —
events first, final response last, stream closed after. The mirrored
metadata headers (MCP-Protocol-Version, Mcp-Method, Mcp-Name
with the base64 sentinel) are validated against the body before
dispatch (-32020 on mismatch), JSON-RPC outcomes map onto HTTP
statuses (-32601 → 404; parse/invalid/-32020..-32022 → 400;
everything else 200), and the Origin allowlist plus the 127.0.0.1
default binding implement the spec's DNS-rebinding defenses. The
server primitive is FPC's own fphttpserver (fcl-web ships inside
FPC 3.2.2, the same reading of the dependency rule that admits
fpjson), confined to the transport unit. The binding is modern-only
(DualEra := False): legacy clients keep using stdio. Each
connection runs on its own thread with its own TMCPSession against
the frozen core — the concurrency model the v1.2.0 state split was
built for. One SDK-anchor fact lives in the transport (verified
2026-08-08 against @modelcontextprotocol/client 2.0.0): the
official client derives the mirrored headers from the body's _meta
envelope and sends its pre-negotiation server/discover probe with no
mirrored header, so header validation keys on the envelope claim rather
than demanding the header on literally every POST. This is only about
not masking the request with a -32020 header error: the body still
carries _meta (server/discover does, like every request), and the
core still answers -32602 for a request missing it — the header
tolerance never lets a bare-_meta request succeed (spec verified
2026-08-08:
https://modelcontextprotocol.io/specification/2026-07-28/server/discover).
Proven end-to-end by the
tools/interop-ts Streamable HTTP battery (http-interop.mjs)
driving mcpdemo --http, including streamed progress notifications.