Table of Contents

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 PublicHost behind a proxy. The server advertises the address it bound, and clients follow discovery URLs into a host they cannot reach.
  • Treating OnGetCustomHeader as a header getter. It is a request gate with var AAllow — it decides whether the request proceeds.
  • Using Headers.Values[…]. That property does not exist. It is Headers.Get(AName) to read and Headers.SetHeader(AName, AValue) to write.
  • Enabling RequireBearerAuthentication with no OnValidateAccessToken. Nothing validates the token, so every request is rejected.
  • Leaving SessionTimeoutMs at 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