Sessions and state
A server that talks to one client at a time can keep its state in fields. A server reached over HTTP cannot: several clients are connected at once, each with its own user, tenant, permissions, and negotiated protocol version, and a tool handler running on a worker thread has to know which of them it is serving. Sessions are how the server keeps those apart. This guide covers which transports have sessions at all, the per-session key/value and user-data stores, resources and prompts visible to one client only, subscriptions, the session lifecycle events, and how protocol version negotiation works per session.
Which transports have sessions
Sessions exist only where the transport can tell clients apart.
TTMSMCPStreamableHTTPTransport tracks one TTMSMCPSessionState per connected
client. STDIO and Named Pipe are single-client by nature, so they are
sessionless: the session APIs are no-ops there, or return empty values and
False. Code that must work on both should treat a nil session as "one
implicit client" rather than as an error.
Resolving the current session
Two methods resolve the session of the request being processed on the calling thread:
| Method | Returns |
|---|---|
CurrentSessionId |
The session id, or '' outside a request. |
CurrentSessionState |
The TTMSMCPSessionState, or nil outside a request. |
"On the calling thread" is the important part. Call them from inside a tool
handler, a resource reader, or OnTaskExecute — all of which run on the thread
that is handling the request. Call them from a timer or a button click and you
get nothing, because no request is in flight there.
For the whole picture, ActiveSessionIds returns every live session and
SessionCount how many there are.
Per-session values and user data
Two stores hang off a session, for two different kinds of state:
A string key/value store, for auth context, tenant ids, and small configuration. Each operation has two overloads — a parameterless one acting on the current request's session, and a session-qualified one for an explicit id:
SetSessionValue(AKey, AValue) SetSessionValue(ASessionId, AKey, AValue)
TryGetSessionValue(AKey, out AValue) TryGetSessionValue(ASessionId, AKey, out AValue)
RemoveSessionValue(AKey) RemoveSessionValue(ASessionId, AKey)
A typed interface store on TTMSMCPSessionState itself, for real objects —
a per-tenant service, a database context, a cached permission set. It is keyed
by TGUID rather than by string, so there is no casting and no name collision:
SetUserData, TryGetUserData, RemoveUserData. Values are released
automatically through interface reference counting when the session ends, which
is why the store takes an IInterface and not a TObject.
procedure TForm1.ConfigureSessions;
begin
Server.OnSessionCreated := ServerSessionCreated;
Server.OnSessionDestroyed := ServerSessionDestroyed;
end;
procedure TForm1.ServerSessionCreated(Sender: TObject; const SessionId: string);
begin
{ Per-session key/value store - handy for tenant or auth context.
The session-qualified overload works from here, where there is no
request in flight on this thread. }
Server.SetSessionValue(SessionId, 'tenant', ResolveTenant(SessionId));
end;
procedure TForm1.ServerSessionDestroyed(Sender: TObject; const SessionId: string);
begin
ReleaseTenantContext(SessionId);
end;
function TForm1.CurrentTenant: string;
begin
{ Called from inside a tool handler: the parameterless overload resolves the
session of the request being processed on this thread. }
if not Server.TryGetSessionValue('tenant', Result) then
Result := '';
end;
procedure TForm1.ExposeSessionLog;
var
State: TTMSMCPSessionState;
Id: string;
begin
{ Also call this from inside a request - CurrentSessionState returns nil
outside one, and on sessionless transports such as STDIO. }
State := Server.CurrentSessionState;
if State = nil then
Exit;
Id := State.SessionId;
State.Resources.RegisterDirectResource('session-log',
'log://' + Id, 'Log for this session', 'text/plain',
function(const URI: string): TTMSMCPResourceContent
begin
Result := TTMSMCPResourceContent.FromText(URI, 'text/plain',
SessionLogText(Id));
end);
end;
Note which overload each part of that example uses. OnSessionCreated runs
outside a request, so it must name the session explicitly; the tool-side helper
runs inside one, so the parameterless overload resolves it.
Session-only resources and prompts
TTMSMCPSessionState carries its own Resources and Prompts collections.
Anything registered there is visible to that one client and nobody else. The
server consults them alongside the server-wide collections when answering
resources/list, resources/templates/list, prompts/list, resources/read,
and prompts/get — server-wide entries are checked first, the session's own act
as a fallback.
This is the mechanism for per-user content: a log only its owner may read, a document set scoped to a tenant, a prompt template that embeds the signed-in user's name.
Subscriptions
When a client calls resources/subscribe, the URI is recorded on
CurrentSessionState.Subscriptions — or server-wide on a sessionless transport.
That gives two ways to announce a change, and they are not interchangeable:
SendResourceUpdatedNotification(AURI)reaches every subscriber of that URI.SendResourceUpdatedForSession(ASessionId, AURI)reaches one session, and only if that session is actually subscribed.
Use the targeted form whenever the change is only meaningful to one client — broadcasting a per-tenant update tells every other tenant that something they cannot see has changed.
Session lifecycle
OnSessionCreated and OnSessionDestroyed both fire with the session id.
Created is the natural place to seed session values and user data, as in the
example above. Destroyed is where you release anything the session owned that is
not reference-counted — interface user data cleans itself up, a registered
external handle does not.
Protocol version per session
The negotiated protocol version is a property of the session, not of the server.
The server supports 2024-11-05, 2025-03-26, 2025-06-18, and 2025-11-25,
and picks one per client during initialize from the client's request and the
server's own ProtocolVersion default.
| Method | Returns |
|---|---|
EffectiveProtocolVersion |
The version for the request on this thread, falling back to the server default when sessionless or outside a request. |
GetSessionProtocolVersion(ASessionId) |
The version for an explicit session, or '' if initialize has not happened yet. |
Feature-gate on these rather than on the server default. Tasks, for instance,
require 2025-11-25, so a client on an older version will not see them however
the server is configured.
Extensions
The extensions framework lets a server declare capabilities beyond the base
protocol and check whether a given client understands them:
RegisterExtension, UnregisterExtension, IsExtensionRegistered,
RegisteredExtensions, and ClientSupportsExtension — the last with both a
current-session and a session-qualified overload. Registered extensions appear
under capabilities.extensions in initialize, and each client's own declared
extensions are recorded on its session so you can branch on them later.
Combining session state with targeted notifications
The parts of this guide are meant to be used together: seed the tenant when the
session is created, resolve the session inside a request, serve content scoped
to that tenant from the session's own Resources, gate optional behaviour on
the version that session negotiated, and announce changes with
SendResourceUpdatedForSession rather than a broadcast:
procedure TForm1.ConfigureTenantServer;
begin
Server.EnableResourceNotifications := True;
Server.EnableResourceSubscriptions := True;
Server.OnSessionCreated := TenantSessionCreated;
end;
procedure TForm1.TenantSessionCreated(Sender: TObject; const SessionId: string);
begin
{ No request in flight here, so name the session explicitly. }
Server.SetSessionValue(SessionId, 'tenant', ResolveTenant(SessionId));
end;
procedure TForm1.ExposeTenantPriceList;
var
State: TTMSMCPSessionState;
Tenant: string;
begin
{ Call from inside a request: CurrentSessionState is thread-scoped and
returns nil outside one, or on a sessionless transport such as STDIO. }
State := Server.CurrentSessionState;
if State = nil then
Exit;
if not Server.TryGetSessionValue('tenant', Tenant) then
Exit;
{ Registered on the session, so no other client ever sees it. }
State.Resources.RegisterDirectResource('price-list',
'prices://' + Tenant, 'Price list for this tenant', 'application/json',
function(const URI: string): TTMSMCPResourceContent
begin
Result := TTMSMCPResourceContent.FromText(URI, 'application/json',
TenantPriceListJSON(Tenant));
end);
{ Tasks need 2025-11-25, so gate on the version this session negotiated
rather than on the server-wide default. }
if Server.EffectiveProtocolVersion = '2025-11-25' then
EnableBackgroundRepricing(State.SessionId);
end;
procedure TForm1.RepriceTenant(const ASessionId, ATenant: string);
begin
RebuildPriceList(ATenant);
{ Targeted, not broadcast - another tenant must not learn that this
tenant's prices changed. }
Server.SendResourceUpdatedForSession(ASessionId, 'prices://' + ATenant);
end;
Each step uses a different mechanism from above — the session-qualified value
store, CurrentSessionState, session-only resources,
EffectiveProtocolVersion, and a targeted subscription update — and the
combination is what keeps a multi-tenant server from leaking the existence of
one tenant's data to another.
Common mistakes
- Calling
CurrentSessionStateoff the request thread. From a timer, a button click, or a background thread you did not start from a handler, it returnsnil. Capture the id inside the request if you need it later. - Assuming sessions exist. On STDIO and Named Pipe there are none. Guard for
niland for''rather than assuming an HTTP-shaped world. - Using the parameterless overload inside
OnSessionCreated. No request is in flight there, so it cannot resolve a session. Use the session-qualified overload. - Broadcasting a per-session change.
SendResourceUpdatedNotificationreaches every subscriber. For tenant-scoped content that is an information leak, not just noise. - Storing a
TObjectas user data. The store takes anIInterfacebecause it releases values by reference counting when the session ends. - Gating features on the server default version. What matters is the version negotiated for that session.
See also
- Configuring the server — capability flags and notifications
- Tasks and elicitation — tasks are session-scoped too
- MCP Transports — which transports carry sessions
TTMSMCPSessionState,TTMSMCPServer