Structuring a Server

Intermediate

Grouping Capabilities into Apps split a server into apps, and showed a provider shared between them. As a server grows, more of its code stops belonging to any one tool: the store every tool reads, the settings they all follow, the audit log every call should reach, the checks that should run before any tool does. This chapter is about where that code goes, so that tools stay small and new ones get it all for free.

In this chapter

Sharing state with providers

A variable at the top of a file is shared by every request, and nothing can replace it in a test. A provider is a class FrontMCP creates for you and hands to any tool that asks with this.get(). Its scope decides how long it lives: GLOBAL for as long as the server runs, CONTEXT for one request. Call close_ticket twice, once for T-1 and once for T-3: the store remembers the first call, and each receipt only has its own steps.

Open
import { App, Provider, ProviderScope, Tool, ToolContext, z } from "@frontmcp/sdk";

@Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL })
export class TicketStore {
  open = new Set(["T-1", "T-3"]);
}

@Provider({ name: "Receipt", scope: ProviderScope.CONTEXT })
export class Receipt {
  steps: string[] = [];
}

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const store = this.get(TicketStore);
    this.get(Receipt).steps.push(store.open.delete(id) ? `closed ${id}` : `${id} was already closed`);
    return { receipt: this.get(Receipt).steps, stillOpen: [...store.open] };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], providers: [TicketStore, Receipt] })
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Ready to learn this topic?

Learn why module-level state breaks under concurrent calls, what GLOBAL and CONTEXT mean per request, how to build a provider from configuration, and how to swap one for a fake in tests.

Read Sharing State with Providers

Extending with plugins

Some behaviour belongs around every tool rather than inside one: an audit log, a maintenance switch, a cache. A plugin (@Plugin) packages it, with its own providers, the tools it adds and the hooks it runs, and an app turns it on in plugins. Call close_ticket, then audit_log:

Open
import { App, DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook, z } from "@frontmcp/sdk";

@Provider({ name: "AuditLog" })
export class AuditLog {
  entries: string[] = [];
}

@Tool({ name: "audit_log", description: "Show every tool call made so far", inputSchema: {} })
export class ShowAuditLog extends ToolContext {
  async execute() {
    return { entries: [...this.get(AuditLog).entries] };
  }
}

@Plugin({ name: "audit", providers: [AuditLog], tools: [ShowAuditLog] })
export class AuditPlugin extends DynamicPlugin<object> {
  @ToolHook.Did("execute")
  async record(ctx: FlowCtxOf<"tools:call-tool">) {
    this.get(AuditLog).entries.push(`${ctx.state.tool?.metadata.name} ${JSON.stringify(ctx.state.toolContext?.input)}`);
  }
}

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "closed" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], plugins: [AuditPlugin] })
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Ready to learn this topic?

Learn what a plugin can add to an app, how it shares its providers, how to make it configurable, where to register it, and what the official plugins do.

Read Extending with Plugins

Hooking into calls

Every tools/call runs through a flow of named stages: find the tool, validate the arguments, execute, format the result. A hook in a plugin runs just before (Will) or just after (Did) a stage, on every call. This one cleans up ticket ids before the schema checks them, so " t-2 " works:

Open
import { App, DynamicPlugin, FlowCtxOf, Plugin, Tool, ToolContext, ToolHook, z } from "@frontmcp/sdk";

@Plugin({ name: "ticket-ids" })
export class TicketIdsPlugin extends DynamicPlugin<object> {
  @ToolHook.Will("validateInput")
  async normalizeTicketId(ctx: FlowCtxOf<"tools:call-tool">) {
    const args = ctx.state.input?.arguments;
    if (typeof args?.id === "string") args.id = args.id.trim().toUpperCase();
  }
}

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Invoice total is wrong", status: "closed" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [TicketIdsPlugin] })
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Ready to learn this topic?

Learn the stages of a tool call, what a hook can read and change at each, how to log calls, block them and rewrite their results, and the order hooks run in.

Read Hooking into Calls

What's next?

Start the chapter with Sharing State with Providers. After it, Securing a Server covers who may call your server and what each caller is allowed to do.