Sharing State with Providers

IntermediateMCP 2026-07-28

Tools rarely work alone. They read the same ticket store, call the same API client and follow the same settings. The quickest way to share something between tools is a variable at the top of a file, and that works right up until two calls run at the same time, or a test needs a different one. A provider is FrontMCP's place for shared things. You declare it with @Provider, register it where it belongs, and any tool that needs it asks for it with this.get(). FrontMCP decides when to create it and how long it lives.

You will learn

  • Why a module-level variable is the wrong place for shared state
  • How @Provider and this.get() share one service between tools
  • What GLOBAL and CONTEXT scopes mean for each request
  • How to build a provider from configuration with a factory
  • How to swap a provider for a fake in tests, and why that decides where you register it

State in a module variable

close_ticket returns a receipt of what it did, so the model can tell the user. It collects the steps in an array that lives at the top of receipt.ts, and empties it at the start of each call:

Open
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { receipt, sendEmail } from "./receipt";

@Tool({
  name: "close_ticket",
  description: "Close a support ticket, and optionally email the customer. Returns a receipt of the steps taken.",
  inputSchema: {
    id: z.string().describe("Ticket id, like T-1"),
    notify: z.boolean().optional().describe("Email the customer that the ticket is closed"),
  },
})
export class CloseTicket extends ToolContext {
  async execute({ id, notify }: { id: string; notify?: boolean }) {
    receipt.length = 0; // start this call's receipt
    receipt.push(`closed ${id}`);
    if (notify) {
      await sendEmail(id);
      receipt.push(`emailed the customer about ${id}`);
    }
    return { id, receipt: [...receipt] };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

One call at a time, the receipt is right. Open the Tests tab to see two calls at once. While the first call waits for the mail server, the second one empties the array and writes its own step, and the first call's receipt comes back with a step from a ticket it never touched. A server handles many requests at the same time, from many clients, so this isn't rare in production.

A module variable belongs to the file, not to the call, and that causes more than this one bug:

  1. Every request shares it. Anything that should belong to one call, like this receipt, leaks between calls.
  2. Nothing can replace it. A test can't give the tool a different store or a fake mail server, because the tool imports the real one directly.
  3. It's built when the file is imported, before your configuration is read or a database is connected.
  4. It's invisible. Nothing in the app's declaration says close_ticket depends on it.

Providers fix all four, and the rest of this lesson shows how. For a value one caller should get back on their next call, like a preference, Remembering Across Calls uses the Remember plugin.

Sharing a service with @Provider

A provider is a class that FrontMCP creates for you. Using one takes three steps: decorate the class with @Provider, list it in the app's providers, and get it inside execute() with this.get():

Open
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

@Tool({
  name: "close_ticket",
  description: "Close a support ticket by its id",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return this.get(TicketStore).close(id);
  }
}

@Tool({
  name: "list_open_tickets",
  description: "List the support tickets that are still open",
  inputSchema: {},
  annotations: { readOnlyHint: true },
})
export class ListOpenTickets extends ToolContext {
  async execute() {
    return { tickets: this.get(TicketStore).open().map((t) => t.id) };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

  • @Provider({ name }) marks the class. name is required and shows up in logs and error messages.
  • providers: [TicketStore] registers it. Decorating the class isn't enough: if neither the tool's app nor the server lists it, the call fails with Provider "TicketStore" is not available.
  • this.get(TicketStore) returns the instance. The class itself is the key you ask with, so this.get() knows the type it returns.

Both tools get the same TicketStore, so a ticket closed by one is gone from the other's list. The tools also say what they depend on now: the app's providers lists it, and a test can put something else in its place.

State for one request: CONTEXT

The ticket store should outlive every call. The receipt should live for exactly one. That's what a provider's scope decides:

ScopeOne instance perCreated
ProviderScope.GLOBAL (the default)place it's registered: an app, or the serverWhen the server starts
ProviderScope.CONTEXTrequest, under MCP 2026-07-28At the start of each tool call

Here is the receipt as a CONTEXT provider. Every this.get(Receipt) during one call returns the same receipt, and the next call gets a new one:

Open
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { Receipt, sendEmail } from "./receipt";

@Tool({
  name: "close_ticket",
  description: "Close a support ticket, and optionally email the customer. Returns a receipt of the steps taken.",
  inputSchema: {
    id: z.string().describe("Ticket id, like T-1"),
    notify: z.boolean().optional().describe("Email the customer that the ticket is closed"),
  },
})
export class CloseTicket extends ToolContext {
  async execute({ id, notify }: { id: string; notify?: boolean }) {
    const receipt = this.get(Receipt);
    receipt.add(`closed ${id}`);
    if (notify) {
      await sendEmail(id);
      receipt.add(`emailed the customer about ${id}`);
    }
    return { id, receipt: receipt.steps };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The code no longer empties anything, because there's nothing left over to empty. The Tests tab runs the same two calls as before, and each receipt has only its own steps. (In Node they run at once. Your browser can't keep two overlapping calls apart, so the Playground runs them one after the other there.)

"Per request" is how MCP 2026-07-28 works: every request stands alone, and there are no sessions to keep a provider alive between them. Clients on older protocol versions open a session, and for them a CONTEXT provider lasts for the whole session instead. If something must survive between calls, don't count on either: make it GLOBAL, or better, keep it in a database.

Deep diveWhen exactly is a provider built?Show detailsHide details

A GLOBAL provider is built once, before the server answers its first request. A CONTEXT provider is built at the start of every tool call in its app, whether or not the tool asks for it. This example counts constructions. Call ping a few times:

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

const built: string[] = [];

@Provider({ name: "Shared", scope: ProviderScope.GLOBAL })
export class Shared {
  constructor() {
    built.push("Shared");
  }
}

@Provider({ name: "PerRequest", scope: ProviderScope.CONTEXT })
export class PerRequest {
  constructor() {
    built.push("PerRequest");
  }
}

@Tool({ name: "ping", description: "List the providers built so far", inputSchema: {} })
export class Ping extends ToolContext {
  async execute() {
    return { built: [...built] }; // never calls this.get()
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [Ping], providers: [Shared, PerRequest] })
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

So keep CONTEXT providers cheap to construct: hold per-request data in them, and leave connections and clients to GLOBAL ones.

Building a provider from configuration

Some things you want to share aren't classes you wrote: a settings object, a client from a library, something that has to be loaded first. For those, register a factory: an object with the key to provide, a name, the providers it needs, and a function that builds the value.

The SLA hours below come from Settings, which stands in for environment variables, so they arrive as strings. The factory turns them into the numbers the tool needs:

Open
import { App } from "@frontmcp/sdk";
import { DeskConfig, Settings } from "./config";
import { ReplyDue } from "./reply-due.tool";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [ReplyDue],
  providers: [
    Settings,
    {
      provide: DeskConfig,
      name: "DeskConfig",
      inject: () => [Settings] as const,
      useFactory: (settings) => ({
        slaHours: {
          high: Number(settings.get("SLA_HIGH_HOURS")),
          normal: Number(settings.get("SLA_NORMAL_HOURS")),
        },
      }),
    },
  ],
})
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A factory has four parts:

  • provide is the key tools pass to this.get(). An abstract class works well: it exists at run time, so it can be a key, and this.get(DeskConfig) knows the value's type. A Symbol works too, with the type passed yourself: this.get<number>(SLA_HOURS).
  • name is required for factories.
  • inject is a function that returns the providers to pass in, in order. With as const, TypeScript infers settings in useFactory.
  • useFactory builds the value. It can be async, for something that has to be loaded first (example).

A factory can take a scope too. It's GLOBAL unless you say otherwise, so this one runs once, when the server starts.

Swapping a provider in tests

snooze_ticket hides a ticket for some hours and says when it comes back. It reads the time from a Clock provider rather than calling new Date() itself, and that's what makes it testable. The real server gives it the system clock:

src/main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { TicketsApp } from "./tickets.app";
import { Clock, SystemClock } from "./clock";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [TicketsApp],
  providers: [{ provide: Clock, name: "Clock", useValue: new SystemClock() }],
})
export default class HelpDeskServer {}

A test can't check the answer against a clock that keeps moving. So the tests start a second entry file, which lists the same app with a clock stopped at 09:00. This Playground runs that test server:

Open
import { FrontMcp } from "@frontmcp/sdk";
import { TicketsApp } from "../src/tickets.app";
import { Clock } from "../src/clock";
import { FixedClock } from "./fixed-clock";

@FrontMcp({
  info: { name: "Help Desk (tests)", version: "1.0.0" },
  apps: [TicketsApp],
  providers: [{ provide: Clock, name: "Clock", useValue: new FixedClock("2026-03-02T09:00:00.000Z") }],
})
export default class TestServer {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The tool and the app are the same code in both servers. Only the entry file differs, and it chooses the clock. In a project, point @frontmcp/testing at the test entry file:

e2e/snooze.e2e.spec.ts
import { test, expect } from "@frontmcp/testing";

test.use({ server: "./e2e/test-server.ts" });

test("a 2-hour snooze from 09:00 ends at 11:00", async ({ mcp }) => {
  const result = await mcp.tools.call("snooze_ticket", { id: "T-1", hours: 2 });
  expect(result.json()).toEqual({ id: "T-1", until: "2026-03-02T11:00:00.000Z" });
});

The same approach works for anything a test shouldn't touch for real: a mail sender that records messages instead of sending them, or an API client that returns canned answers.

An app's own provider comes first

Notice that TicketsApp doesn't register Clock. When a tool calls this.get(), FrontMCP looks in the tool's own app first and only then in the server. So an app that registers a provider hides the server's provider with the same key. That's handy for overrides: here the whole server replies within 24 hours, and the VIP app within 2.

Open
import { FrontMcp } from "@frontmcp/sdk";
import { TicketsApp, VipApp } from "./apps";
import { deskConfig } from "./config";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [TicketsApp, VipApp],
  providers: [deskConfig(24)],
})
export default class HelpDeskServer {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The same rule works against you in tests. If TicketsApp registered SystemClock itself, the test server's FixedClock would never be seen, and the snooze test would fail. Register a provider that differs between environments on the server, in each entry file, and keep it out of the app. The last challenge below starts from exactly that mistake.

Recap

  • A module-level variable is shared by every request and every test, can't be replaced, and doesn't show up as a dependency. Use a provider.
  • Decorate the class with @Provider({ name }), list it in providers, and get it with this.get(). The class is the key.
  • GLOBAL (the default) is one instance per place it's registered. CONTEXT is a new instance for each request under MCP 2026-07-28, built at the start of every tool call.
  • A factory (provide, name, inject: () => [...], useFactory) builds a provider from other providers or configuration, and can be async.
  • this.get() looks in the tool's app first, then in the server. Register providers that differ between environments on the server, and swap them in a test entry file.

Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press Check.

Challenge 1 of 3

Give each call its own receipt

The receipt is a provider now, but the second call's receipt still starts with the first call's steps. Make every request get a fresh receipt, without emptying it by hand.

Open
import { Provider, ProviderScope } from "@frontmcp/sdk";

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

  add(step: string) {
    this.steps.push(step);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.