Tools
From a hello-world class to a fully documented tool: types, descriptions, and every JSDoc tag.
A tool is a plain TypeScript function that AI agents can call. In a Quartal Plugin you never write schemas by hand — you write a class with typed methods, and the framework generates the MCP tool definitions and the OpenAPI (REST) actions from your code and JSDoc comments.
Hello world
Create a class in src/tools/ and export it from src/tools/mod.ts:
// file: src/tools/HelloWorld.ts
/** A class that says hello to the world. */
export class HelloWorld {
/**
* Greets the caller by name.
* @param input The greeting parameters.
* @returns A friendly greeting.
*/
sayHello(input: { /** Name of the person to greet. */ name: string }): string {
return `Hello, ${input.name}!`;
}
}
// file: src/tools/mod.ts
// Every public method of every class exported here becomes
// an MCP tool AND an OpenAPI / REST action.
export * from "./HelloWorld.ts";
That’s it. Run pnpm dev and your plugin now serves:
- an MCP tool named
sayHelloon the/mcpendpoint - a REST action at
POST /api/HelloWorld/sayHello - documentation for both at
/(the built-in docs site)
Both instance methods and static methods work. private methods (and private properties) are
never exposed.
How a method becomes a tool
| Tool field | Comes from | Notes |
|---|---|---|
name |
Method name | Prefixed with the class name (Class_method) only when the same method name exists in two classes. Characters outside a–z A–Z 0–9 _ - are replaced with _; max 128 characters. |
title |
@summary JSDoc tag |
Optional short display name. |
description |
JSDoc body | The full comment text above the tags; paragraphs are kept. This is the main text the agent reads. |
inputSchema |
The method’s first parameter | See Input types. Any further parameters are treated as injected context (auth, request, …) and never appear in the schema. |
outputSchema |
The method’s return type | Promise<T> is unwrapped to T. See Output types. |
_meta.ui.resourceUri |
A widget page for this tool | Added automatically when the tool has a widget. |
_meta.ui.visibility |
@visibility JSDoc tag |
Who may call the tool — see @visibility below. |
Input types
Design your tool method to take a single input object — its properties become the schema properties directly (the object is “unwrapped”, there is no extra nesting level). The input type can be inline (like the hello world above) or a named interface, even one imported from another package:
// file: src/tools/CreateUserInput.ts
/** Input for creating a user. */
export interface CreateUserInput {
/** User's full name. */
name: string;
/**
* Valid email address.
* @format email
*/
email: string;
/**
* User's age, optional.
* @format int32
*/
age?: number;
/** User role. */
role: "admin" | "user";
}
TypeScript → JSON Schema
| You write | The agent sees |
|---|---|
string, number, boolean |
{ "type": "string" | "number" | "boolean" } |
Foo[], Array<Foo> |
{ "type": "array", "items": … } |
| Named interface / type alias | Inlined object schema with all its properties; extends chains are flattened in. |
"a" | "b" | "c" (string literal union) |
{ "type": "string", "enum": ["a", "b", "c"] } |
Other unions (Foo | Bar) |
{ "oneOf": [ …, … ] } |
name?: string (optional) |
Property present, but omitted from required. |
string | null |
Treated as string; the property is still required unless also optional. |
any, unknown, unresolvable types |
{} (anything allowed) |
object, Record<string, …> |
{ "type": "object", "additionalProperties": true } |
| Recursive types (a tree node referencing itself) | The nested occurrence becomes an open object — recursion is cut, not followed. |
Input schemas are strict: additionalProperties: false and every non-optional property is
listed in required. This is intentional — the agent constructs the input, and a strict schema
steers it to the exact shape your method expects.
Output types
The return type produces the tool’s outputSchema. A tool result is always a JSON object; if
your method returns a primitive or an array, the runtime wraps it — and the schema reflects that:
| Return type | Runtime result | Output schema shape |
|---|---|---|
Invoice (object type) |
The object as-is | Object schema with its properties |
number / string / boolean / array |
{ "value": 5 } |
{ "value": … } |
void / undefined |
{} |
Empty object |
Output schemas are deliberately looser than input schemas: extra properties are allowed, no
property is required, and every type is widened to also accept null. MCP clients validate
results against the advertised schema, and real-world results (especially from upstream APIs)
routinely contain nulls and undeclared fields — a strict output schema would make legitimate
results fail. In short: the input schema is a contract the agent must fulfill; the output schema
is documentation of what the agent can expect.
Descriptions
Descriptions and types are the most important information passed via MCP all the way to the AI agent — they are how the agent decides whether and how to call your tool.
- The JSDoc body on the method becomes the tool’s
description. - The JSDoc body on each property becomes that property’s
description. - Write a description for every property; use
@examplefor anything non-obvious.
JSDoc tag reference
Tags on the tool method
| Tag | Purpose | Example |
|---|---|---|
@summary |
Short display name — MCP title / OpenAPI summary. |
@summary Create invoice |
@param |
Description of the input object itself (first parameter). | @param input The invoice fields. |
@returns |
Description of the output schema. | @returns The created invoice. |
@visibility |
Who may call the tool — see below. | @visibility app |
Tags on input/output properties
| Tag | Purpose | Example |
|---|---|---|
@summary |
Short display name — the property’s schema title. |
@summary Due date |
@format |
JSON Schema format for string properties: date, date-time, time, duration, email, uri, uri-reference, uuid, … |
@format date |
@example |
Example value — parsed as JSON when possible, otherwise kept as text. | @example { "name": "Ada" } |
@visibility
By default every tool is visible to the AI model and callable by your plugin’s own widgets.
The @visibility tag narrows that, using the scopes defined by the
MCP Apps standard:
model— the tool is visible to and callable by the AI agent.app— the tool is callable by this plugin’s widgets (the “app”) only.
/**
* Returns paged rows for the invoice-list widget.
*
* The model should call `listInvoices` instead; this variant exists for
* the widget's infinite scroll and is hidden from the model.
* @visibility app
*/
async fetchInvoiceRows(input: FetchInvoiceRowsInput): Promise<InvoiceRowsPage> { … }
Multiple scopes are space- or comma-separated (@visibility model, app). Omitting the tag is the
same as allowing both — hosts default to ["model", "app"]. The value is advertised to MCP
clients as _meta.ui.visibility on the tool.
Typical use: a widget helper tool (@visibility app) that returns fine-grained UI data the
model should never call directly — keeping the model’s tool list small and focused.
Practical tips
- Write a JSDoc description for every method and property — descriptions are the main thing the agent reads when deciding how to call your tool.
- Use string literal unions (
"fi" | "sv" | "en") for closed value sets — they becomeenums the agent cannot get wrong. - Mark truly optional properties with
?— everything else becomesrequiredin the input schema. - Use
@exampleon non-obvious properties; agents follow examples closely. - Keep the model’s tool list small: mark widget-only helpers with
@visibility app.