HTTP transports
Once a server has to serve more than one client, or carry state that differs per client, it has to live on the network — and that brings everything the local transports let you skip: a port, a certificate, CORS, session lifetime, and some answer to "who is calling". TMS AI Studio has two HTTP transports. Streamable HTTP is the current one: a single endpoint, real per-client sessions, TLS, CORS control, and OAuth 2.1 support. SSE is the earlier two-endpoint shape, kept for clients that expect it. This guide covers both, then the deployment concerns that only appear once a server is reachable.
Streamable HTTP
This is the transport to choose for anything hosted. One endpoint handles the whole protocol, and the transport assigns each connecting client a session that the server's session APIs then operate on:
procedure TForm1.StartHTTPServer;
var
Transport: TTMSMCPStreamableHTTPTransport;
begin
Transport := TTMSMCPStreamableHTTPTransport.Create(Server);
Transport.Port := 8443;
Transport.MCPEndpoint := '/mcp';
{ TLS. On Windows the HTTP server sits on http.sys, so the certificate is
bound to the port ahead of time with netsh and CertFile acts only as a
fallback pointer into the certificate store. The PEM-file form applies
to the non-Windows implementation. }
Transport.UseSSL := True;
Transport.CertFile := ConfigPath('server.crt');
Transport.KeyFile := ConfigPath('server.key');
{ Sessions are purged once idle for this long. }
Transport.SessionTimeoutMs := 300000;
Transport.CleanupIntervalMs := 60000;
Server.Transport := Transport;
Server.Start;
end;
Port and MCPEndpoint define where it listens. UseSSL, CertFile,
KeyFile, and KeyPassword configure TLS — but what those properties mean
depends on the platform, and this catches people out:
| Platform | HTTP implementation | What CertFile means |
|---|---|---|
| Windows | http.sys, the kernel component IIS itself uses | Not a PEM file. Bind the certificate to the port ahead of time with netsh http add sslcert using its thumbprint; CertFile is consulted only as a fallback pointer to a certificate already in the Windows certificate store. |
| Other platforms | An OpenSSL-based server | A real PEM file path, with KeyFile and KeyPassword alongside. |
So on Windows there is no code path that loads a raw .crt/.key pair, and a
friendly name will not do — netsh and the fallback both want the SHA-1
thumbprint. That fallback has a shape: CertFile names an INI file carrying
the certificate's Hash, StoreName, and AppID, and when nothing is bound to
the port yet the server runs the netsh binding itself from those values.
Authentication has the file format and the
binding commands.
Sessions and their lifetime
Streamable HTTP is the only transport with real sessions, which is what makes the per-session state described in Sessions and state usable.
Two properties govern how long they live. SessionTimeoutMs (default 300000, so
five minutes) is how long an idle session survives before being purged.
CleanupIntervalMs (default 60000) is how often the transport looks for expired
ones. Raise the timeout when clients are slow or intermittent; lower it when
sessions hold expensive resources that should not linger.
The cost of a purge is real: everything on the session goes with it — values, user data, session-only resources and prompts, subscriptions — and the client's next request starts a fresh session with none of it.
Server-Sent Events
The SSE transport uses two endpoints: one the client holds open to receive events, one it posts messages to. Its constructor takes them together with the port:
procedure TForm1.StartSSEServer;
var
Transport: TTMSMCPSseTransport;
begin
Transport := TTMSMCPSseTransport.Create(Server, 8080, '/sse', '/message');
{ ConfigureSSL sets UseSSL, CertFile, KeyFile and KeyPassword together.
On Windows these point into the certificate store, not at PEM files. }
Transport.ConfigureSSL(ConfigPath('server.crt'), ConfigPath('server.key'));
Server.Transport := Transport;
Server.Start;
end;
ConfigureSSL is a convenience that sets UseSSL, CertFile, KeyFile, and
KeyPassword in one call; DisableSSL reverses it, and IsSSLConfigured
reports the state. The endpoints default to /sse and /message and are
settable through SSEEndpoint and MessageEndpoint.
Use SSE when a client requires that shape. For new work, prefer Streamable HTTP.
Running behind a reverse proxy
A hosted server is usually fronted by IIS, nginx, or similar: the transport binds to a local port, and the proxy terminates the public name. That creates a mismatch, because the server generates absolute URLs — OAuth discovery documents, protected-resource metadata — and by default it builds them from the address it bound, which no client can reach.
PublicHost is the fix:
procedure TForm1.ConfigureBehindProxy(ATransport: TTMSMCPStreamableHTTPTransport);
begin
{ The transport listens on localhost; IIS or another proxy fronts it.
Without PublicHost the URLs the server generates would point at the
internal address, which no client can reach. }
ATransport.Port := 8081;
ATransport.MCPEndpoint := '/mcp';
ATransport.PublicHost := 'https://mcp.example.com';
{ Cache the CORS preflight so browsers stop re-asking on every call. }
ATransport.CORSMaxAgeSeconds := 86400;
end;
It accepts two forms, and the difference matters:
| Form | Example | Scheme comes from |
|---|---|---|
| Full URL | https://mcp.example.com |
The string. Used verbatim as a complete override. |
Bare host[:port] |
mcp.example.com |
This transport's own UseSSL, with its port appended if none is given. |
Behind a reverse proxy, always use the full-URL form. The bare form derives
its scheme from the transport's own UseSSL, which is typically False there
because TLS was terminated upstream — so it re-creates exactly the wrong-scheme
problem PublicHost exists to solve. The bare form is for a server exposed
directly, with no proxy in front.
CORSMaxAgeSeconds (default 86400, so 24 hours) becomes the
Access-Control-Max-Age header, caching the CORS preflight so browser clients
stop re-asking on every call. Set it to 0 and the header is omitted entirely.
Caching the preflight is not the same as permitting the origin. The MCP endpoint
itself only accepts a request whose Origin header is absent or names
localhost / 127.0.0.1; anything else is rejected with
403 {"error": "Invalid origin"} before it reaches the server, and there is no
property that widens the set. A browser client on another origin has to be
fronted by a proxy that presents itself as same-origin.
Custom headers
Two events bracket a request. Despite their names they are not a getter/setter pair — one screens the request before it is dispatched, the other adds headers to the response:
| Event | Signature | Use |
|---|---|---|
OnGetCustomHeader |
(Sender, ARequest, var AAllow, var AErrorMessage) |
Inspect the incoming request and reject it by setting AAllow := False. |
OnSetCustomHeader |
(Sender, ARequest, AResponse) |
Add headers to the outgoing response. |
procedure TForm1.ConfigureHeaders(ATransport: TTMSMCPStreamableHTTPTransport);
begin
ATransport.OnGetCustomHeader := TransportGetCustomHeader;
ATransport.OnSetCustomHeader := TransportSetCustomHeader;
end;
procedure TForm1.TransportGetCustomHeader(Sender: TObject;
ARequest: TTMSMCPHTTPServerRequest; var AAllow: Boolean;
var AErrorMessage: string);
var
Tenant: string;
begin
{ Runs before the request is dispatched. Set AAllow to False to reject it;
AErrorMessage is reported back to the caller. }
Tenant := ARequest.Headers.Get('X-Tenant');
if Tenant = '' then
begin
AAllow := False;
AErrorMessage := 'Missing X-Tenant header';
Exit;
end;
if not TenantIsActive(Tenant) then
begin
AAllow := False;
AErrorMessage := 'Unknown or suspended tenant';
end;
end;
procedure TForm1.TransportSetCustomHeader(Sender: TObject;
ARequest: TTMSMCPHTTPServerRequest; AResponse: TTMSMCPHTTPServerResponse);
begin
{ Runs while the response is being built - stamp anything the caller
should see alongside the protocol payload. }
AResponse.Headers.SetHeader('X-Correlation-Id', CurrentCorrelationId);
end;
Read incoming headers with ARequest.Headers.Get(AName) and write outgoing ones
with AResponse.Headers.SetHeader(AName, AValue); Exists and GetIfExists are
there for optional headers.
OnGetCustomHeader is a useful gate for a cheap, transport-level check — a
required tenant header, an IP allow-list. It is not a substitute for token
validation.
Bearer authentication and OAuth 2.1
The Streamable HTTP transport can require an OAuth 2.1 bearer token and publish the discovery documents a client needs to obtain one:
| Property | Role |
|---|---|
EnableOAuthDiscovery |
Serves the authorization-server metadata documents. |
OAuthMetadata |
A custom metadata document; a default is generated when unassigned. |
RequireBearerAuthentication |
Rejects any request without a valid token. |
AuthorizationServers |
The issuers a client may obtain a token from. |
ResourceScopesSupported |
Every scope this resource understands. |
RequiredScopes |
The scopes a token must carry to be accepted. |
ProtectedResourceMetadata |
A custom RFC 9728 document; a default is generated when unassigned. |
OnValidateAccessToken |
Where you actually verify the token. |
OnValidateAccessToken receives the token and the resource URI and fills a
TTMSMCPAccessTokenValidation
record — Valid, Subject, ClientId, Scopes, ExpiresAt, and on rejection
ErrorCode and ErrorDescription. Verifying the token is yours to implement;
the transport handles the challenge, the metadata documents, and enforcing
RequiredScopes against what you report.
An unauthenticated request gets a 401 carrying a WWW-Authenticate header
pointing at the protected-resource metadata, which is how a compliant client
discovers where to authenticate.
Combining TLS, public host, sessions, and authentication
A production deployment uses all of it at once, and the pieces are
interdependent — PublicHost is what makes the OAuth metadata documents point
somewhere reachable, and those documents are what make RequireBearerAuthentication
usable by a client that has not been configured by hand:
procedure TForm1.StartProductionServer;
var
Transport: TTMSMCPStreamableHTTPTransport;
begin
Transport := TTMSMCPStreamableHTTPTransport.Create(Server);
Transport.Port := 8443;
Transport.MCPEndpoint := '/mcp';
{ TLS terminated here rather than at the proxy. On Windows, bind the
certificate to the port with netsh first - see the Authentication guide. }
Transport.UseSSL := True;
Transport.CertFile := ConfigPath('server.crt');
Transport.KeyFile := ConfigPath('server.key');
{ The address clients use, which is not the address we bind. }
Transport.PublicHost := 'https://mcp.example.com';
Transport.CORSMaxAgeSeconds := 86400;
{ Session lifetime for the per-client state the server keeps. }
Transport.SessionTimeoutMs := 600000;
Transport.CleanupIntervalMs := 60000;
{ OAuth 2.1: advertise discovery, demand a bearer token, and validate it. }
Transport.EnableOAuthDiscovery := True;
Transport.RequireBearerAuthentication := True;
Transport.AuthorizationServers.Add('https://auth.example.com');
Transport.ResourceScopesSupported.Add('inventory.read');
Transport.ResourceScopesSupported.Add('inventory.write');
Transport.RequiredScopes.Add('inventory.read');
Transport.OnValidateAccessToken := TransportValidateAccessToken;
Server.Transport := Transport;
Server.Start;
end;
procedure TForm1.TransportValidateAccessToken(Sender: TObject;
const AToken, AResourceURI: string; var AValidation: TTMSMCPAccessTokenValidation);
begin
{ Verify the token against the authorization server, then report the
outcome so the transport can accept or reject the request. }
AValidation := ValidateWithAuthServer(AToken, AResourceURI);
end;
Read it as four decisions: where it listens and under what certificate, what address it claims to be, how long per-client state survives, and who is allowed in. Getting the third and fourth right is what separates a demo from a deployment.
For the full hosting story — binding a certificate on Windows, running as a service, IIS rewrite rules, and RFC 8414/9728 path insertion — see Authentication.
Common mistakes
- Forgetting
PublicHostbehind a proxy. The server advertises the address it bound, and clients follow discovery URLs into a host they cannot reach. - Treating
OnGetCustomHeaderas a header getter. It is a request gate withvar AAllow— it decides whether the request proceeds. - Using
Headers.Values[…]. That property does not exist. It isHeaders.Get(AName)to read andHeaders.SetHeader(AName, AValue)to write. - Enabling
RequireBearerAuthenticationwith noOnValidateAccessToken. Nothing validates the token, so every request is rejected. - Leaving
SessionTimeoutMsat the default for slow clients. Five minutes idle and the session — and everything on it — is gone. - Serving plain HTTP outside localhost. Bearer tokens on an unencrypted connection are readable in transit.
- Choosing SSE for new work. Streamable HTTP is the current transport; SSE is compatibility.
See also
- Choosing a transport — which transport, and why
- Authentication — certificates, services, and reverse proxies
- Sessions and state — what sessions make possible
TTMSMCPStreamableHTTPTransport,TTMSMCPSseTransport,TTMSMCPHTTPServer