Hosting attributed servers
Attributes describe a surface; something has to read them. That something is
TTMSMCPAttributedServer, a
TTMSMCPServer descendant that keeps a collection of targets — the classes and
object instances it reflects over — and rebuilds its tools, resources, and
prompts from them. Everything a hand-built server offers still applies:
transports, capabilities, sessions, notifications, and tasks are unchanged,
because the attributed server only replaces the registration step. This guide
covers registering targets, when the reflection pass actually runs, who owns
what, giving a target a way back to its server, and the resource and prompt
attributes the pass also reads.
Creating the server
Reach for the server directly when you have more than one target, more than one
transport, or anything else to configure; reach for the factory when you have
exactly one class to expose. TTMSMCPAttributedServer is an ordinary component —
drop it on a form or create it in code with an owner:
procedure TForm1.BuildAttributedServer;
var
Target: TTMSMCPTarget;
begin
Server := TTMSMCPAttributedServer.Create(Self);
Server.ServerName := 'Workshop';
Server.ServerVersion := '1.0.0';
{ A class target exposes class methods only - no instance is needed. }
Server.AddClass(TUnitConverter);
{ An instance target also exposes ordinary methods. AddObject leaves the
instance yours; set OwnsObject so the target frees it instead. }
Target := Server.AddObject(TCatalogService.Create);
Target.OwnsObject := True;
{ Targets are matched by class and by instance, so adding the same target
again returns the existing entry instead of duplicating its tools. }
Server.AddClass(TUnitConverter);
Memo1.Lines.Add(Format('%d target(s), %d tool(s) generated',
[Server.Targets.Count, Server.Tools.Count]));
end;
TTMSMCPServerFactory is the
one-liner for the common case. CreateFromObject creates a server and registers
an instance; CreateFromClass does the same for a class. Both accept an optional
owner component, and both return a server whose tools are already generated:
program CatalogServer;
{$APPTYPE CONSOLE}
uses
System.SysUtils,
TMS.MCP.Attributes.Server,
TMS.MCP.Transport.STDIO,
CatalogService in 'CatalogService.pas';
var
Service: TCatalogService;
Server: TTMSMCPAttributedServer;
begin
Service := TCatalogService.Create;
try
{ CreateFromObject reflects over the class straight away, so the server
already carries one tool per attributed method when it returns. }
Server := TTMSMCPServerFactory.CreateFromObject(Service);
try
Server.ServerName := 'Catalog';
Server.ServerVersion := '1.0.0';
Server.Start;
Server.Run;
finally
Server.Free;
end;
finally
{ The factory does not take ownership of the service instance. }
Service.Free;
end;
end.
Class targets and instance targets
Choose between the two according to what your methods need, because the
reflection pass treats them differently. A class target — added with
AddClass — can only invoke class function and class procedure members,
since there is no instance to call an ordinary method on; instance methods on a
class-only target are skipped. An instance target — added with AddObject —
exposes both, so any method that touches fields, a database connection, or
anything else the object holds needs one.
Both are stored as a TTMSMCPTarget in the
server's Targets collection, and both are
deduplicated: AddClass and AddObject first look for an existing entry with
FindByClass / FindByObject and return that instead of adding a second one.
Registering the same target twice is therefore harmless rather than a source of
duplicate tools.
When the reflection pass runs
This is the detail that decides whether your server has any tools at all.
TTMSMCPAttributedServer.AddClass and TTMSMCPAttributedServer.AddObject add
the target and run the reflection pass immediately. The collection's own
Targets.AddClass and Targets.AddObject only add the entry. So build your
targets through the server's methods, and treat Targets as the place to inspect
what is registered rather than the place to register it.
The pass is a full rebuild, not an append: it clears Tools, Resources, and
Prompts before walking every target. Two consequences are worth planning
around. Adding a second target regenerates the first target's tools as well,
which is harmless. But any tool you registered by hand on the same server is
discarded by the next AddObject or AddClass call — so add every target first,
then register hand-built tools on top.
AutoRegister and Targets are both published, so a server dropped on a form
keeps its target list in the form file and is configurable in the Object
Inspector.
Ownership
Ownership is per target and is not implied by registration. AddObject leaves
the instance yours: the demo pattern of creating the service, passing it to the
factory, and freeing it after the server is correct. To hand an instance over
instead, set OwnsObject on the returned target — it is then freed when the
target is destroyed, which happens when the server is.
Pick one of the two for each instance. A target with OwnsObject set that you
also free yourself is a double free, and an instance that neither owns is a leak
in a long-running process that rebuilds its targets.
Giving a target its server
A target is a plain object, so by default it knows nothing about the server that
calls it — which is limiting the moment a tool wants to send a log notification,
raise a change notification, or look at the current session. Implement
ITMSMCPServerAware and the server hands
itself to the target while registering it:
{ In the interface section of your service unit: }
type
TJobService = class(TInterfacedPersistent, ITMSMCPServerAware)
private
FServer: TTMSMCPAttributedServer;
public
{ Called by the server while the target is being registered. }
procedure SetMCPServer(AServer: TObject);
[TTMSMCPToolAttribute]
[TTMSMCPNameAttribute('queue_job')]
[TTMSMCPDescriptionAttribute('Queues a background job and returns its identifier')]
function QueueJob(const AKind: string): string;
end;
implementation
uses
System.SysUtils;
procedure TJobService.SetMCPServer(AServer: TObject);
begin
FServer := AServer as TTMSMCPAttributedServer;
end;
function TJobService.QueueJob(const AKind: string): string;
begin
Result := JobQueue.Enqueue(AKind);
{ With the server in hand the target reaches the rest of the protocol -
here it sends a log notification the client can display. }
if Assigned(FServer) then
FServer.SendLogMessage(llInfo, 'jobs',
Format('Queued %s job %s', [AKind, Result]));
end;
Derive such a target from a non-reference-counted base such as
TInterfacedPersistent. A target descending from TInterfacedObject starts at a
reference count of zero, and the interface query the server performs takes and
releases a reference — which destroys the object before it has served a single
call.
Resource and prompt metadata
The reflection pass reads three families of markers, not one. Alongside
[TTMSMCPToolAttribute] it recognises [TTMSMCPResourceAttribute] and [TTMSMCPPromptAttribute], and
fills in their metadata from these attributes:
| Attribute | Applies to | Effect |
|---|---|---|
[TTMSMCPResourceAttribute] |
Method | Registers the method as a resource. Requires a URI or a URI template. |
[TTMSMCPURIAttribute] |
Resource method | Sets the resource's fixed URI. |
[TTMSMCPURITemplateAttribute] |
Resource method | Sets the resource's URITemplate for a parameterised family of URIs. |
[TTMSMCPMimeTypeAttribute] |
Resource method | Sets the advertised MimeType. |
[TTMSMCPPromptAttribute] |
Method | Registers the method as a prompt; each parameter becomes a prompt argument. |
[TTMSMCPCompletionAttribute] |
Prompt parameter | A comma-separated candidate list the server offers as completions for that argument. |
[TTMSMCPNameAttribute], [TTMSMCPTitleAttribute], [TTMSMCPDescriptionAttribute], [TTMSMCPIconAttribute] |
All three | Identity, wording, and icons, exactly as for a tool. |
A resource needs [TTMSMCPURIAttribute] or [TTMSMCPURITemplateAttribute]; one with neither is
skipped. [TTMSMCPOptionalAttribute] works on a prompt parameter too, making that
argument optional. Completion candidates are matched case-insensitively on the
prefix the user has typed so far, and the server wires its own completion
handler only when OnCompletion is unassigned — so a handler of your own always
wins. Remember to set EnableCompletions on the server, or the capability is
never advertised.
Note that the generated reader and handler resolve their target method through
the same lookup the tool dispatcher uses. A method marked only
[TTMSMCPResourceAttribute] or only [TTMSMCPPromptAttribute] therefore contributes its metadata
but is not invoked. Register resource readers and prompt handlers explicitly
through Resources and Prompts
on the same server, and use attributes for the tool surface.
Putting a whole server together
The form below combines everything in this guide: a class target for stateless conversions, an owned server-aware instance target for stateful work, a Streamable HTTP transport so clients connect to the process rather than launching it, and a pass over the generated tool list to confirm what was produced:
procedure TForm1.FormCreate(Sender: TObject);
var
Jobs: TJobService;
Target: TTMSMCPTarget;
I: Integer;
begin
Server := TTMSMCPAttributedServer.Create(Self);
Server.ServerName := 'Workshop';
Server.ServerVersion := '2.1.0';
{ Class target: TUnitConverter exposes class methods, so no instance. }
Server.AddClass(TUnitConverter);
{ Instance target: TJobService implements ITMSMCPServerAware, so the server
calls SetMCPServer on it during this AddObject call. }
Jobs := TJobService.Create;
Target := Server.AddObject(Jobs);
Target.OwnsObject := True;
{ A transport the client connects to, rather than launches. }
Transport := TTMSMCPStreamableHTTPTransport.Create(Self, 8934, '/mcp');
Server.Transport := Transport;
{ Every attributed method across both targets is now a tool. }
for I := 0 to Server.Tools.Count - 1 do
Memo1.Lines.Add(Server.Tools[I].Name + ' - ' + Server.Tools[I].Description);
Server.Start;
end;
procedure TForm1.FormDestroy(Sender: TObject);
begin
Server.Stop;
{ Server owns Targets, and the owning target frees the TJobService
instance, so nothing else has to be released here. }
end;
Listing Tools after registration is the quickest sanity check there is. An
empty list almost always means a method is not public, or that the targets went
into the collection without going through the server.
Common mistakes
- Registering through
Targetsinstead of the server.Server.Targets.AddObject(…)adds the entry but does not run the reflection pass, so the server ends up with a target and no tools. UseServer.AddObject/Server.AddClass. - Registering hand-built tools before adding a target. Each
AddObjectorAddClassclearsTools,Resources, andPromptsfirst. Add all targets, then add anything you build by hand. - Expecting instance methods from a class target.
AddClasswithout an instance exposes class methods only. The rest are skipped silently. - Freeing an instance the target owns. Setting
OwnsObjecthands the instance to the target. Freeing it yourself as well is a double free; the factory andAddObjectdo not set it, so by default the instance stays yours. - Deriving a server-aware target from
TInterfacedObject. Reference counting frees it during the server's interface query. UseTInterfacedPersistentor another non-counted base. - Publishing a resource or prompt by attribute alone. The metadata registers, but the handler is not reached. Wire those handlers explicitly.
See also
- Declaring tools with attributes — the method-level attributes
- Parameters and schema — how arguments are declared
- MCP Server — capabilities, sessions, and notifications
- MCP Transports — how a client reaches the server
TTMSMCPAttributedServer,TTMSMCPTargets,TTMSMCPServerFactory