This module is an MCP server, so a caller registers ordinary C++ procedures and serves them as tools that a client can list and call. It is not an MCP client. The unit of work is a tool: a named procedure, a JSON Schema for its parameters, and a content type for its result. JSON-RPC is the envelope. Pt::Remoting supplies the procedure table. This module adds the MCP methods, the tool schemas, and the transports.
Work proceeds in four steps. A Pt::Remoting::ServiceDefinition holds the C++ functions or methods. A ToolDeclaration holds the matching tool names, descriptions, and parameter schemas. Each tool may name a ContentType for its result; the default is text. A transport then serves that pair: HttpService on an HTTP server, or Service / StdioService on standard streams.
The tool name in the declaration must be the procedure name in the definition. Arguments and results must be serializable, as in the other remoting modules: a type that can be written to SerializationInfo can be a tool parameter or a return value. Custom types are welcome when they have that support.
The declaration and the definition are not owned by the transport. Both must outlive the HttpService, Service, or StdioService that uses them. A Type named by a tool parameter must outlive that tool. Primitive schema types from integerType() and the other factories are process-wide singletons. Composed types (ObjectType, ArrayType, EnumType, NullableType) are owned by the caller. ToolDeclaration owns the Tool objects it creates. A ContentType used with Tool::setContent() must outlive the tool; textContent() and imageContent() are process-wide singletons.
The protocol methods are initialize, tools/list, and tools/call. Initialize negotiates a protocol version and returns the server name and version from the declaration. Tools/list returns the declared tools and their input schemas. Tools/call looks up the tool, maps named arguments onto the procedure, and writes the result as MCP content. A request without an id is a notification and produces no JSON-RPC body.
Supported protocol versions are 2025-11-25 and 2025-03-26. ToolDeclaration::preferredVersion() returns the requested version when it is supported, otherwise the latest. A tool that throws Pt::JsonRpc::Fault or Pt::Remoting::Fault becomes an MCP error response. Other failures are reported as JSON-RPC errors by the transport.
The example registers two procedures, declares the matching tools with schemas, and serves them over HTTP. The stdio path is the same declaration and definition with StdioService.
The rest of this chapter is parameter schemas, then tool declarations, then result content, then the HTTP transport, then standard I/O.
This chapter covers:
A tool parameter is described by a Type, which is a JSON Schema fragment the declaration writes into tools/list. The schema is what the client sees. The C++ argument type of the procedure is what the remoting layer deserializes. Those two descriptions must agree: an integer schema belongs on an int parameter, an object schema on a serializable struct.
Primitive schemas are process-wide singletons. nullType(), integerType(), numberType(), stringType(), and booleanType() return them. They are never constructed by the caller and they outlive every tool.
Composed schemas are values the caller owns. ObjectType is a JSON object with named properties. ArrayType is an array whose items have one element type. EnumType is a string restricted to a fixed set of values. NullableType wraps another type so null is also accepted. The composed type stores a pointer to each inner Type; it does not copy it and it does not own it. Keep every inner type alive for as long as the composed type, and keep the composed type alive for as long as any Tool that names it.
Property is the named slot both objects and tools use: a name, a Type, a description, and a required flag. Properties and tool parameters are required by default. ObjectType::setOptional() and Tool::setOptional() clear that flag by name. ObjectType::setStrict() forbids additional properties in the schema.
Type::toSchema() writes the JSON Schema object to a stream. The declaration calls it when it formats tools/list. Application code rarely calls it directly. Type is not copyable.
The example builds an object schema with a required string and an optional integer, then uses it as a tool parameter. The object type must outlive the declaration.
Type is the schema node the parameter-schema group described. It is either a primitive from integerType() and the other factories, or a composed type the caller owns. typeId() is the JSON Schema kind. toSchema() writes that kind as a JSON object, with an optional description. Composed types override toSchema() to add properties, items, enum values, or null.
The type does not own other types. An ObjectType, ArrayType, or NullableType stores pointers to the types it names, and those types must outlive it. Type is not copyable.
Property is the name, type, description, and required flag the schema group uses for both ObjectType members and Tool parameters. The type is not owned; it must outlive the property. A new property is required. setOptional() clears that flag.
ObjectType is a composed Type whose schema is a JSON object. addProperty() appends a required property. setOptional() makes an existing property optional by name; a name that is not present is ignored. setStrict() writes additionalProperties as false so undeclared members are rejected.
Each property stores a pointer to its Type. Those types must outlive this object type. The object type must outlive every Tool that uses it as a parameter.
ArrayType is a composed Type whose schema is a JSON array. The items type is the schema of every element. It is not owned; it must outlive this array type.
EnumType is a composed Type whose schema is a JSON string with an enum list. addValue() appends an allowed string. The type id is Type::String. The caller owns the enum type and must keep it alive while a tool names it.
NullableType wraps another Type so the schema allows null as well as that inner type. For primitives the schema uses a type array, for example {"type":["string","null"]}. For objects and arrays it uses oneOf. The inner type is not owned; it must outlive this wrapper.
ToolDeclaration is the server-facing catalog. It holds the server name and version that initialize returns, and the tools that tools/list returns. Tool is one entry in that catalog: a name, a description, a list of parameters, and a content type for the result.
addTool() creates a Tool owned by the declaration and returns it so parameters can be chained. The tool name must match a procedure already registered, or later registered, on the Pt::Remoting::ServiceDefinition the transport will use. The declaration does not look up that procedure; a missing or mismatched name fails at tools/call.
addParam() appends a parameter. The name is the JSON member the client sends and the name used to find the positional argument of the procedure. The Type is the schema. The description is copied into that schema. Parameters are required until setOptional() is called with the same name. setContent() selects how the result is written; the default is textContent().
toInitializeResult() writes the initialize payload, including the negotiated protocol version. toToolsList() writes the tools/list payload from the declared tools. Transports call both. Supported versions are 2025-11-25 and 2025-03-26. preferredVersion() returns the requested version when it is in that set, otherwise the latest. isSupportedVersion() tests a version string.
The declaration owns every Tool it creates and deletes them in its destructor. It does not own the Type objects or the ContentType a tool refers to. The declaration is not copyable.
Tool is one callable in the catalog ToolDeclaration owns. The name is the MCP tool name and must match a procedure on the Pt::Remoting::ServiceDefinition. The description is what tools/list shows. addParam() appends a parameter whose name is the JSON member the client sends. Parameters are required until setOptional() is called with that name; a name that is not present is ignored.
setContent() selects the ContentType for the result. The default is textContent(). The content type and every parameter Type must outlive this tool. getParamIndex() maps a parameter name to its position, or -1 when the name is unknown. The tool is not copyable. Application code does not construct a Tool; it comes from ToolDeclaration::addTool().
ToolDeclaration is the server catalog the tools group described. The constructor stores the server name and version that initialize returns. addTool() creates a Tool, owns it, and returns it so addParam() and setContent() can be chained. getTool() finds a tool by name, or returns a null pointer.
toToolsList() writes the tools/list result JSON from the declared tools and their schemas. toInitializeResult() writes the initialize result JSON, echoing protocolVersion when it is not null and the latest supported version otherwise. Transports call both. Application code rarely writes those payloads itself.
preferredVersion() returns requested when it is a supported protocol version, otherwise the latest supported version. isSupportedVersion() tests a version string. The supported versions are 2025-11-25 and 2025-03-26.
The declaration owns every Tool it creates. It does not own the Type or ContentType objects those tools refer to. The declaration is not copyable.
A successful tools/call result is not raw JSON of the return value. It is an MCP content array, and each block has a type such as text or image. ContentType chooses that block. The responder decomposes the procedure result and drains it through a ContentFormatter the content type creates.
getFormatter() returns a new ContentFormatter. The caller owns it until releaseFormatter(). beginContent() writes the opening bytes of the content array and of the concrete block, then returns the Pt::Formatter the decomposer writes into. finishContent() writes the closing bytes. The output stream is passed to those two calls, not to getFormatter().
textContent() is the default. It serializes the result as compact YAML-like text and wraps it in a text block. imageContent() expects a result that decomposes to a single Binary node, encodes those bytes as base64, and wraps them in an image block with MIME type image/png. Construct an ImageContent with another MIME type when the image is not PNG.
textContent() and imageContent() return process-wide singletons. They may be used by many responders at once. getFormatter() must therefore be stateless on the ContentType: per-request state lives only in the ContentFormatter. A custom ContentType follows the same rule. Subclass ContentType and ContentFormatter, keep immutable configuration on the content type, and put buffers and the inner formatter on the formatter instance.
Tool::setContent() stores a pointer. The ContentType must outlive the tool. The builtin singletons do.
ContentFormatter is the per-request writer the result-content group described. beginContent() writes the opening bytes of the content array and of the concrete block (for example a text member), then returns the Pt::Formatter the decomposed result is drained into. finishContent() flushes buffered output and writes the closing bytes. The formatter is not itself a Pt::Formatter; it wraps one.
Instances come from ContentType::getFormatter() and must be released with ContentType::releaseFormatter(). Subclass this type to implement a custom block: onBeginContent() writes the type-specific opening JSON, onBeginFormat() returns the inner formatter, and onFinishContent() writes the closing JSON. write() and output() are for those overrides.
ContentType chooses how a decomposer result becomes an MCP content block. getFormatter() creates the ContentFormatter used to write that block. The caller drives beginContent(), the decomposer begin/advance protocol, then finishContent(), and releases the formatter with releaseFormatter() whether the format completed or was abandoned.
Implementations used as process-wide singletons, including textContent() and imageContent(), may run on many responders at once. getFormatter() must be stateless on the content type: it may read immutable members set at construction and must not cache a formatter, stream, or scratch buffer as a mutable member. All per-request state belongs on the ContentFormatter.
TextContent serializes the procedure result with a compact YAML-like formatter and embeds that text in an MCP text content block. textContent() is the process-wide instance and the default for every Tool.
ImageContent serializes a decomposed binary result as a base64-encoded MCP image content block. The tool's return value must decompose to a single Binary node. The bytes are encoded as they are decomposed, without buffering the whole image. The MIME type is set at construction and defaults to image/png.
imageContent() is the process-wide instance with that default MIME type. A different MIME type needs a caller-owned ImageContent that outlives the tool.
HttpService is an Pt::Http::Service that makes one ToolDeclaration and one Pt::Remoting::ServiceDefinition available as an HTTP resource. It does not listen. An Pt::Http::Server listens, and an Pt::Http::MapUrl (or another servlet) maps a path to this service. Each request gets a responder that runs initialize, tools/list, or tools/call.
Only POST is accepted. A different method is 405. When an Origin header is present, its host must match the Host header; a mismatch is 403. After initialize, a request may send MCP-Protocol-Version; an unsupported value is 400. A notification (a JSON-RPC request without an id) is answered with 202 and no body.
The service does not own the definition or the declaration. Both must outlive the service, and the service must outlive every responder it still has in flight, as any Pt::Http::Service must. The HTTP server is asynchronous, so it needs an EventLoop. Tool procedures that are asynchronous run on that loop.
The example is the mapping. The server, the service, and the declaration come from the module chapter.
HttpService is the HTTP transport the HTTP group described. It is an Pt::Http::Service: the server asks it for a responder, and that responder handles one POST as initialize, tools/list, or tools/call. Attach it to an Pt::Http::Server with Pt::Http::MapUrl or another servlet. It does not listen and it does not implement MCP on GET.
The constructor stores the Pt::Remoting::ServiceDefinition and the ToolDeclaration. Neither is owned. Both must outlive this service, and this service must outlive any responder it has not yet released. Asynchronous tool procedures run on the server's EventLoop.
Standard I/O is the local transport. Messages are JSON-RPC bodies framed with a Content-Length header and a blank line, as MCP clients use on stdin and stdout. readMessage() consumes one framed message and returns the JSON body, or an empty string on EOF. writeMessage() writes the header and the body. dispatch() runs initialize, tools/list, or tools/call on that body and returns the response JSON, or an empty string for a notification.
Service is the synchronous form. It takes a Pt::Remoting::ServiceDefinition and a ToolDeclaration. dispatch() runs the procedure on the calling thread. Use it when every tool is a synchronous procedure.
StdioService is the same framing and the same methods, with an EventLoop. Asynchronous service procedures need that loop: dispatch() starts the procedure and runs the loop until the result is ready. The loop is not owned. Keep it alive for as long as the service is used.
Neither type owns the definition or the declaration. A typical stdio server reads from std::cin and writes to std::cout in a loop until readMessage() returns empty.
Service is the synchronous stdio transport. It reads and writes Content-Length framed JSON-RPC messages and dispatches initialize, tools/list, and tools/call on the calling thread. Use it when every tool is a synchronous procedure. Asynchronous procedures need StdioService and an EventLoop.
readMessage() consumes one framed message from a stream and returns the JSON body, or an empty string on EOF. writeMessage() writes a Content-Length header and the JSON body. dispatch() runs the request and returns the response JSON, or an empty string for a notification.
The Pt::Remoting::ServiceDefinition and the ToolDeclaration are not owned. Both must outlive this service.
StdioService is the stdio transport that can run asynchronous tool procedures. Framing and dispatch are the same as Service: readMessage(), writeMessage(), and dispatch() on Content-Length framed JSON-RPC. The extra argument is an EventLoop. dispatch() starts the procedure and, when the procedure is asynchronous, runs that loop until the result is ready.
Use this type when a tool is an asynchronous remoting procedure. Use Service when every tool completes on the calling thread. The loop is not owned; it must outlive this service. The Pt::Remoting::ServiceDefinition and the ToolDeclaration are not owned either.
A typical server reads from stdin and writes to stdout until readMessage() returns empty. dispatch() returns an empty string for a notification, and that string is not written.