Table of Contents

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 CurrentSessionState off the request thread. From a timer, a button click, or a background thread you did not start from a handler, it returns nil. Capture the id inside the request if you need it later.
  • Assuming sessions exist. On STDIO and Named Pipe there are none. Guard for nil and 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. SendResourceUpdatedNotification reaches every subscriber. For tenant-scoped content that is an information leak, not just noise.
  • Storing a TObject as user data. The store takes an IInterface because 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