Skip to content

MCP 2026-07-28 with backwards compatibility for 2025-06-18 and 2025-11-25 - #40

Open
cmgeuze wants to merge 92 commits into
mainfrom
feature/mcp-2026-07-28
Open

MCP 2026-07-28 with backwards compatibility for 2025-06-18 and 2025-11-25#40
cmgeuze wants to merge 92 commits into
mainfrom
feature/mcp-2026-07-28

Conversation

@cmgeuze

@cmgeuze cmgeuze commented Sep 4, 2026

Copy link
Copy Markdown
Member

Summary

The server serves MCP 2026-07-28 (per-request _meta, server/discover, stateless HTTP, resultType and cache hints, multi round-trip requests, streamed notifications, subscriptions/listen) and the initialize-based revisions 2025-06-18 and 2025-11-25 on the same HTTP endpoint and the same stdio process. Library consumers keep their tools, resources and managers; new behaviour is documented in MIGRATION.md, the full list of changes in CHANGELOG.md.

Highlights, each merged through its own PR into this branch:

  • Protocol core: era detection, _meta validation, -32020/-32021/-32022, HTTP status mapping, server/discover, cache hints, header mirroring (Mcp-Method, Mcp-Name).
  • Tools, resources, prompts, resource templates, completion, JSON Schema 2020-12 validation, content samples for the conformance suite.
  • stdio: LF framing without BOM, worker threads, cancellation, progress, drain on EOF.
  • Multi round-trip requests with HMAC-sealed requestState; streamed progress and log notifications on HTTP responses; subscriptions/listen with change notifications.
  • Opt-in bearer authentication with OAuth 2.1 resource-server discovery, [RequiresScope], host allow-list, diagnostics switch.
  • coding-rules.md records the conventions the library keeps on purpose.

Verification

  • DUnitX: 370/370 on Win64 and Win32, no leaks, zero hints and warnings; Linux64 builds.
  • Conformance 2026-07-28: 155 passed, no scored failures (only the Tasks extension and the pending custom-header scenario fail, neither is scored). 2025-11-25: 59 passed, the 8 remaining failures are the deprecated features this server deliberately does not serve (logging/setLevel, server-initiated sampling and elicitation, resources/subscribe).
  • HTTP goldens, Inspector smoke over legacy, auto, modern and stdio (26 tools) and the stdio smoke run pass.

cmgeuze and others added 30 commits September 3, 2026 12:02
Pins the JSON-RPC layer (37 cases through TMCPJsonRpcProcessor with the same
registry as MCPServer.dpr) and the Streamable HTTP transport (26 curl cases
against the built executable) as they behave on the unchanged 2025-06-18 code.

- tests/MCPServer.Tests.dpr: DUnitX console runner, Win32 and Win64
- tests/MCPServer.Tests.Golden.pas: golden loader, mask and shape
  normalisation, record mode via MCP_GOLDEN_RECORD=1
- build-tests.bat, scripts/run-tests.ps1, scripts/capture-http-goldens.ps1
- tests/golden/README.md documents the format, the recording procedure and
  the current defects the goldens pin (logs://recent double free, nil params)
Moves the JSON-RPC error codes to MCPServer.Types and adds the constants
the 2026-07-28 work needs: protocol revisions and version sets, the MCP
error codes -32020/-32021/-32022 (and the legacy -32002), the reserved
_meta keys and the list of cacheable methods.

MCPServer.JsonRpcProcessor keeps JSONRPC_* as aliases so consumer code
compiles unchanged; the unused duplicate block in MCPServer.IdHTTPServer
is removed. MCP_PROTOCOL_VERSION stays '2025-06-18'.

Also makes the HTTP golden comparison trim trailing newlines on both
sides (SSE bodies end with a blank line).
TServerStatusResource.IncrementRequestCount, ConnectionOpened and
ConnectionClosed run on every Indy connection thread; they now use
AtomicIncrement and a compare-and-swap loop that never goes below zero,
and GetResourceData reads the counters atomically. GetNextEventID uses
AtomicIncrement as well.

TMCPRegistry creates its dictionaries in a class constructor instead of
lazily, and documents that registration must complete before the managers
are created. SetNamePrefix documents the same constraint.

Tests: concurrent counter updates, below-zero guard, registry contents.
TMCPStdioTransport.Create now forces TLogger.UseStdErr and sets the new
TLogger.StdoutReserved guard. While the guard is set, console logging
always goes to stderr and setting UseStdErr back to False is refused with
a one-time warning on stderr. Library consumers that create the transport
themselves no longer corrupt the MCP channel with log lines.

Tests: guard forces stderr, refusal warns once, release restores the
previous behaviour, transport constructor sets the guard.
…st baselines

scripts/run-conformance.ps1 builds and starts the server and runs the
official conformance CLI for the frozen 2026-07-28 and 2025-11-25
requirement sets against the same endpoint. One expected-failures file per
set (conformance-baseline-<revision>.yml), because a scenario can pass on
one wire and fail on the other and a passing baseline entry counts as
stale. The baselines record the current state: 36 scored failures for
2026-07-28, 22 for 2025-11-25.

scripts/run-inspector-smoke.ps1 lists tools through the Inspector CLI for
the legacy, auto and modern eras over HTTP and for stdio (ci-servers.json);
legacy, auto and stdio pass, modern fails until server/discover exists.

scripts/run-stdio-smoke.ps1 drives --stdio through cmd redirection and
checks the framing: one JSON object per line on stdout, logs on stderr.
It also records that non-ASCII stdin is decoded with the ANSI code page.

package.json pins the Node tooling (conformance 0.2.0-alpha.11 for the
--requirements flag, Inspector 2.5.0).
README: library checklist (register before start, stdout rules for stdio,
server://status is opt-in, constants in MCPServer.Types), resource list
matching what the executable registers, automated-tests section.
CHANGELOG.md starts with the unreleased phase-0 entries.
…le recording

- logs://recent answered "Invalid pointer operation": the copied entries were
  owned by two lists and freed twice.
- TMCPSerializer wrote TList<T> and TObjectList<T> properties as an object
  with count and capacity; they are JSON arrays now, so project://info lists
  its features and logs://recent its entries.
- server://status was declared but never registered; the unit registers it
  by default and SetNamePrefix replaces that registration
  (TMCPRegistry.UnregisterResource is new).
- resources/read without params is handled like a missing uri, and
  tools/call without arguments hands the tool an empty object, instead of
  dereferencing nil.

Goldens re-recorded for the affected cases (resources-list,
resources-read-project-info, resources-read-logs-recent,
resources-read-server-status, resources-read-without-params,
tools-call-missing-arguments) plus the HTTP resources cases; every other
golden is unchanged.
The golden README, the fixture comment, the CHANGELOG and the baseline
comments describe what the files are, not when they were made or what
comes next. The Inspector smoke script takes the entries that must fail
as a parameter instead of assuming a fixed set.
Test harness, golden files and hygiene fixes
The processor decides the protocol era per request in BuildRequestContext:
initialize is always legacy, a params._meta with
io.modelcontextprotocol/protocolVersion is modern, everything else is
legacy (negotiated stdio revision, MCP-Protocol-Version header, or
2025-11-25). Modern requests get their _meta validated (-32602), unknown
revisions -32022 with the supported list, header/body disagreement -32020,
and the legacy-only methods -32601. Modern results carry resultType,
_meta.serverInfo and, for the cacheable methods, ttlMs and cacheScope.
The HTTP status for each outcome is computed here; the transport still
answers 200 for everything.

- MCPServer.Errors: EMCPError with code, data, HTTP status and factories
- MCPServer.RequestContext: IMCPRequestContext implementation, thread-local
  Current, TMCPTransportHints
- MCPServer.Capabilities: capabilities derived from the registered managers
- MCPServer.CoreManager: server/discover; initialize negotiates the revision,
  drops sessionId and the non-schema capability keys; no session state
- MCPServer.Types: era, request id, session slot, new interfaces, version
  helpers; managers expose IMCPCapabilityProvider; the registry enumerates
  its managers and injects itself into IMCPRegistryAware managers
- Settings: Title, Description, WebsiteUrl, Instructions, LenientModernPing,
  DiscoverListsLegacyVersions, DiscoverTtlMs
- Transports pass hints (header, stdio session) and use ProcessRequestEx;
  the HTTP server no longer scrapes sessionId from the body
- TRequestContextTests: one test per branch of BuildRequestContext
- TProcessorTests: status policy, modern envelope, discover, client
  responses, error data, thread-local context, 50 concurrent initializes
- TCapabilityBuilderTests
- tests/golden/modern: 16 cases replayed through the JSON-RPC layer;
  TGoldenRunner shared by the legacy and modern fixtures
- Legacy goldens re-recorded where the wire changed on purpose: initialize
  (negotiated revision, cleaned capabilities, no sessionId), batch arrays,
  id null, missing method or jsonrpc, params not an object, server/discover
  without _meta
- HTTP goldens: six modern cases added; initialize no longer carries an
  Mcp-Session-Id header
- Conformance baselines regenerated: 2026-07-28 goes from 32 to 87 passed
  checks; the Inspector smoke run now succeeds in the modern era as well
README: dual-era badge, feature line and the section "Protocol Versions
and Dual-Era Behaviour"; CHANGELOG entries for the protocol core.
Dual-era JSON-RPC core: server/discover and per-request _meta
…rrored headers

TMCPIdHTTPServer now runs the pipeline the transport spec describes:
Origin validation on every request (403 with a JSON-RPC body and
Vary: Origin; loopback origins on any port pass, the allow-list comes from
[Security] AllowedOrigins or [CORS] AllowedOrigins, null is refused), CORS
headers only when enabled, 404 for other paths, 204 for OPTIONS, 405 with
Allow for anything but POST, then the processor's HTTP status is answered.
Notifications and client responses get 202 with an empty body, 4xx
answers carry a JSON-RPC error body, SSE responses lose the id: line.

The server binds to loopback when Host is loopback (both 127.0.0.1 and
::1 when IPv6 is available) and to every interface otherwise; BindAddress
overrides. MaxRequestBodyBytes (413), MaxJsonDepth (400), MaxConnections
and an optional EndpointInfoPath are new settings. The OpenSSL 1.0.2
handler offers TLS 1.2 only. USE_TAURUS_TLS lives in src\MCPServer.inc.

Modern requests must mirror method and name into Mcp-Method and Mcp-Name
(MCPServer.HttpHeaders decodes the Base64 sentinel form strictly); a
missing or different header is -32020 with 400. A legacy request with an
unknown MCP-Protocol-Version header gets 400. An initialize that carries
modern _meta is a modern request and therefore an unknown method (404),
as a modern client probing the server expects.

Bodies are logged at Debug level through TLogger.RedactJson.
- THttpTransportTests drives TMCPIdHTTPServer on an ephemeral port with
  TIdHTTP: 202 empty body, 405 with Allow, 204, 404, Origin policy with
  and without CORS, CORS headers and preflight reflection, status codes
  per era, Mcp-Method and Mcp-Name validation, 413 and depth limits,
  session echo for legacy only, SSE without id line, loopback binding,
  EndpointInfoPath
- THttpHeadersTests: sentinel decoding table, Accept parsing, Origin
  policy, JSON depth scanner
- The test program is tests\MCPServerTests.dpr: a dotted program name made
  the compiler resolve the Indy unit IdHTTPServer as MCPServer.IdHTTPServer
- HTTP goldens re-recorded for the new transport, with modern header cases;
  conformance baselines regenerated (2026-07-28 goes from 87 to 110 passed
  checks, dns-rebinding-protection passes on both wires)
MIGRATION.md lists every behaviour change with what to configure; README
gains the network and security settings and the HTTP status rules;
CHANGELOG entries for the transport.
Dual-era Streamable HTTP: Origin check, status codes, mirrored headers, loopback bind
TMCPToolResult builds text, image, audio, embedded resource and resource
link content blocks with structuredContent, _meta and isError.
TMCPToolBase<T> gains ExecuteWithContext next to ExecuteWithParams and
EMCPToolError for failures reported as isError. Tools publish annotations
and icons (IMCPToolMetadata); resources publish title, size, annotations
(IMCPResourceMetadata), blob contents (IMCPBinaryResource) and cache hints
(IMCPCacheableResource). New schema attributes SchemaTitle, SchemaFormat,
SchemaMinimum and SchemaMaximum; EMCPError.UnknownTool and
EMCPError.ResourceNotFound carry data.name and data.uri.
Deserialisation rejects a missing required parameter, a wrong JSON type,
a fraction for an integer and an unknown enumeration name with an
EArgumentException naming the parameter; null counts as absent.
Serialisation writes enumerations by name, sets and dynamic arrays as
arrays, nil objects as null and TDateTime as ISO 8601.
The schema generator emits integer for integers, date-time strings for
TDateTime, enum names, typed arrays for sets, dynamic arrays and TList<T>,
nested object schemas and additionalProperties: false for parameter-less
tools.
tools/call answers -32602 with data.name for an unknown tool and for a
missing name or non-object arguments; argument validation errors and
EMCPToolError become isError results; every result has a content array
and a typed result gets a text block next to structuredContent.
resources/read answers -32002 (initialize-based) or -32602 (modern) with
data.uri for an unknown URI, -32603 for a failing read, and delivers
IMCPBinaryResource contents as blob. Lists follow registration order,
carry resource metadata and, in the modern era, ttlMs and cacheScope.
AddTool and AddResource register instances outside TMCPRegistry.
One small tool per content type (test_simple_text, test_image_content,
test_audio_content, test_embedded_resource, test_multiple_content_types,
test_error_handling) and the resources test://static-text and
test://static-binary, the fixtures the conformance suite calls.
logs://recent no longer logs its own reads; project://info names the
current protocol revisions and is cacheable for an hour.
DUnitX fixtures for TMCPToolResult, the serializer, the schema generator
and the tools and resources managers in both eras. Goldens re-recorded
for the new tools, schemas, error codes and ISO timestamps; conformance
baselines pruned of the content-block and binary-resource scenarios that
pass now, with resources-templates-read added.
Tools and resources: content blocks, validation and error codes
…lation

UTF-8 byte streams (MCPServer.StdioChannel) replace Text I/O, which decoded
stdin with the console code page and mangled non-ASCII input. A reader
thread answers notifications, client responses and legacy ping inline;
every other request goes through a queue to MaxConcurrentRequests worker
threads (default 1, so responses keep arriving in order).

notifications/cancelled stops the named request and it gets no response
(IMCPRequestContext.IsCancelled/CheckCancelled/Cancel, IMCPRequestTracker).
A request with _meta.progressToken gets notifications/progress before its
response (IMCPRequestContext.ReportProgress, monotonic and throttled).

On EOF, in-flight work drains for ShutdownDrainMs before the rest is
cancelled, so the process always exits promptly. A stdio server never
writes settings.ini next to the executable; the console-control and
signal handlers, and the debug leak report, are skipped in stdio mode.
Line framing (LF splitting, CR stripping, BOM skipping, UTF-8 decoding,
overlong and invalid-UTF-8 lines), the message writer, cancellation and
progress on the request context, and the transport end to end over
in-memory streams (handshake, UTF-8 round trip, duplicate ids, cancelled
requests, progress ordering, modern requests, shutdown draining).
test_tool_with_progress reports notifications/progress for each step and
stops when the client cancels; the tools and resources managers now also
let a cancelled request propagate instead of turning it into a protocol
error.
The smoke test now drives a progress token, a cancellation and a ping
through the real executable and checks framing, ordering, and that the
process exits promptly. Goldens re-recorded for the new sample tool.
cmgeuze and others added 22 commits September 4, 2026 08:44
[Security] AllowedHosts rejects requests whose Host header is not
listed with 403, so a rebinding DNS name cannot reach a server that is
published under a public name. [Server] ExposeDiagnosticsResources=0
keeps logs://recent, logs://{level} and server://status off the
executable; TMCPResourcesManager gains RemoveResourceTemplate.
Host allow-list and diagnostics resources switch
The PowerShell wrappers, the Node tooling, the Inspector server list,
the conformance baselines and the curl-captured HTTP goldens leave the
repository. The DUnitX project covers the processor and both transports
in-process; MCP_GOLDEN_RECORD=1 re-records its goldens.
Keep the DUnitX project as the repository's test tooling
The requestState key comes from the operating system random generator
instead of the clock-seeded RNG, the canonical JSON encoder no longer
leaks a string per object member, and a signed payload that is not an
object is rejected instead of raising a cast error.

An HTTP response stream refuses writes after its terminator, the stdio
transport counts subscription threads before it frees what they use,
releases a reserved request id when starting it fails and bounds its
shutdown push, the subscriptions manager waits for its listeners, the
resources manager guards its lookups with the same lock as its
mutations, and the progress throttle is guarded like cancellation.

Results are fully initialised on every path, an unknown registry entry
is a typed exception instead of a message match, a non-JSON result is an
internal error instead of a leak, a nested dispatch restores the outer
context, invalid UTF-8 on stdin is detected instead of replaced, CORS
headers precede a rejection and header matching compares whole tokens, a
body larger than the limit is discarded instead of buffered, an
introspection failure is a 401, an unusable schema pattern is a
validation error, a duplicate template variable is refused, a log level
is percent-encoded into its URI, and sets and integers survive
serialisation intact.
Security, lifetime and correctness fixes from the review
…ecords

Content blocks, protocol version checks and log redaction move onto
TMCPContentBlock, TMCPProtocolVersion and TLogger; the schema generator
keeps its RTTI context in a class variable; the origin parser becomes
TMCPOriginParts; the tools manager and the request context tests own
their helpers. Nothing raises the base Exception any more:
EMCPRegistryNotFound, EMCPError.MethodNotFound and
EMCPConfigurationError take its place. Functions that reported through
out parameters now return a record (TMCPAuthResult, TMCPLine,
TMCPCompiledTemplate) or carry a Try prefix.
Types instead of loose routines, typed exceptions and result records
MCPServer.Types carries the method names, the JSON-RPC envelope keys,
the media types and the JSON member names that the whole server uses,
so cacheScope, the JSON-RPC version, the event-stream media type and
the wildcard scope exist once instead of in four units. Every unit that
repeated a literal now names it: capability names, tool, prompt and
resource names, schema keywords, ini sections and the recurring
messages.
One constant per protocol value, no repeated literals
ProcessRequestEx reads the request through named steps, the request
context is built per era, the schema validator validates per JSON type,
the schema generator describes per type kind, the serializer converts
per type kind, the subscriptions manager splits opening, waiting and
closing, and the HTTP handler reads, checks and answers in named
methods. TMCPServerApplication replaces the wiring that the program
file repeated for each transport, and Exit with a call reads as an
assignment and a return.
Shorter methods and a server application class
Every for and while body has begin and end, a boolean operator ends its
line instead of starting the next, an attribute sits on its own line
with a blank line between test declarations, and the comparisons whose
meaning fits a name are named first.
House rules across the sources and the tests
The runner insists on assertions again, so a test that asserts nothing
now fails instead of passing silently. The concurrent template read
counts mismatches and asserts on the main thread, because assertions
made in worker threads do not reach the runner.

A golden mismatch is an assertion failure with the expected and actual
text, not an exception. Registration checks describe the registered set
instead of counting it, so adding a tool no longer breaks them.

The progress throttle only judges the dropped burst when the burst
really stayed inside the interval, and the remaining waits poll a
condition through a shared helper. Stdio lookups that must find a
message fail with the received lines instead of returning nil.

New unit MCPServer.Tests.Support holds the shared protocol fragment,
the JSON object parser and the wait helper. Test doubles expose
properties instead of public fields, and unused units are gone.
test: make the suite fail on faults it could hide
Authentication and Network and Security sit under Configuration, the
Commercial Support section was missing from the list, and the example
sections are capitalised like the entries that point at them. The
performance bullet states what the server uses instead of claiming
speed.
docs: keep the README contents in step with its sections
MarkReadOnly on the three tool bases writes the readOnlyHint and
openWorldHint pair a client uses to decide about parallel or
auto-approved calls, instead of every tool building the annotations
object itself. Calling it twice leaves one pair per hint.

test_simple_text uses it, so tools/list now publishes
openWorldHint: false next to its readOnlyHint; both goldens are
re-recorded for it.
feat: mark a tool read-only in one call
cmgeuze and others added 6 commits September 7, 2026 14:07
The check compared two absolute paths with a plain StartsWith, so any
sibling directory whose name begins with the working directory's name
(mcp-server-secrets next to mcp-server) passed as if it were inside it.
TPathBoundary.IsWithin accepts the base directory itself and anything
below it, and nothing else.

Reported by Syed Anas Mohiuddin.
…2026

fix(list_files): anchor the allowed-directory check on a path separator (2026 branch)
The conventions the file listed are visible in the code itself; the
contributing section names the ones a pull request has to follow.
docs: drop the internal coding rules from the public repository
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant