@Adapter

@Adapter declares an adapter: a class that builds tools, resources and prompts from something other than your code, like an API's spec, the saved views in your help desk's admin, or a catalog in a database. FrontMCP calls its fetch() when the server starts and serves what it returns next to the app's own entries; the adapter can replace them while the server runs. Extend DynamicAdapter to get init(), so an app lists a configured copy in adapters. The OpenAPI adapter is one, written this way. To add behaviour around every call rather than entries, write a plugin.

@Adapter({ name })
class MyAdapter extends DynamicAdapter<Options> {
  options: { name: string } & Options;
  fetch(): FrontMcpAdapterResponse { /* { tools, resources, prompts } */ }
}

@App({ adapters: [MyAdapter.init({ name, ...options })] })

Reference

@Adapter(options)

Apply @Adapter to a class that extends DynamicAdapter, and list MyAdapter.init({ name, ... }) in the adapters of an @App, a @Plugin or @FrontMcp. This adapter turns each of the help desk's saved views, a named ticket filter, into a tool:

saved-views.adapter.ts
import { Adapter, DynamicAdapter, tool, type FrontMcpAdapterResponse } from "@frontmcp/sdk";
import { TicketStore, type TicketFilter } from "./ticket-store";

export interface SavedViewsOptions {
  views: { name: string; description: string; filter: TicketFilter }[];
}

@Adapter({ name: "saved-views", description: "A tool for each saved ticket view" })
export class SavedViewsAdapter extends DynamicAdapter<SavedViewsOptions> {
  options: { name: string } & SavedViewsOptions;

  constructor(options: { name: string } & SavedViewsOptions) {
    super();
    this.options = options;
  }

  fetch(): FrontMcpAdapterResponse {
    return {
      tools: this.options.views.map((view) =>
        tool({ name: `view_${view.name}`, description: view.description, inputSchema: {} })((_input, ctx) => ({
          tickets: ctx.get(TicketStore).find(view.filter),
        })),
      ),
    };
  }
}
help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { SavedViewsAdapter } from "./saved-views.adapter";
import { TicketStore } from "./ticket-store";

@App({
  id: "help-desk",
  name: "Help Desk",
  providers: [TicketStore],
  adapters: [
    SavedViewsAdapter.init({
      name: "views",
      views: [{ name: "open_billing", description: "Open billing tickets", filter: { status: "open", tag: "billing" } }],
    }),
  ],
})
export class HelpDeskApp {}

See more examples below.

Options

OptionTypeDescription
namestringRequired. Names the adapter class in FrontMCP's debug logs. The name that matters to clients is each copy's own, options.name: see names.
descriptionstringFor people reading the code. Clients never see it.
idstringAccepted and unused in FrontMCP 1.9.4.

adapters only takes a class with @Adapter, or what its init() returns: anything else fails when the @App decorator runs, with adapters items must be annotated with @Adapter().

DynamicAdapter<Options>

The base class for adapters, exported by @frontmcp/sdk. Options is the type of what the adapter is configured with, besides name.

MemberDescription
optionsRequired. Declare it as options: { name: string } & Options and set it in the constructor. FrontMCP reads options.name; the rest is yours. TypeScript reports a class without it.
fetch()Required. Returns the adapter's entries, { tools?, resources?, prompts? }, or a promise of them. See what fetch() returns.
static init(options)Returns a record to list in adapters. options needs a non-empty name, unique among this class's init() calls in the whole process. Calls your constructor with options right away, when your module is imported.
static init({ name, inject, useFactory })Builds the adapter when the server starts: useFactory gets the providers inject returns, and returns the options, all but name, or a promise of them. FrontMCP calls your constructor with them and name. inject is optional. The factory may instead return an adapter it built, whose options.name must be name. See building the options when the server starts. Changed in 1.9: a factory's options were taken for the adapter, and the server didn't start.

Listing the class itself, adapters: [MyAdapter], calls its constructor with no options, which suits a class whose options are written in the class. A DynamicAdapter that sets options from its constructor's argument fails that way with Expected an adapter.

Optional methods

FrontMCP calls these when the adapter defines them, in this order:

MethodWhenWhat to do
setLogger(logger)Before fetch()Keep the logger. Its lines are labelled adapter:<options.name>.
onUpdate(callback)After the entries from fetch() are registeredKeep callback, and call it with a new { tools?, resources?, prompts? } whenever your source changes. Return a function that drops it.
startPolling()After onUpdate(), once for each adapter instanceStart watching your source, like a timer that checks it.
stopPolling()When the server is disposed, after the function onUpdate() returned is calledStop watching: clear the timer. An adapter that several servers serve is stopped when the last of them is disposed.

Changed in 1.9: disposing a server didn't call stopPolling() or drop the onUpdate() callback.

What fetch() returns

FrontMcpAdapterResponse has three lists, of the kinds an @App takes:

FieldHolds
tools@Tool classes, or tools written with tool()
resources@Resource classes, or resource()
prompts@Prompt classes, or prompt()

There's no field for agents, skills, jobs or providers: put those in the app. The entries are served as the app's own and get the app's providers, with this.get() in a class and ctx.get() in a function. A fetch() that throws or rejects stops the server from starting, with its error.

A new response passed to the onUpdate() callback replaces the lists it has: { tools: [] } removes every tool the adapter added and keeps its resources and prompts. A client connected with server.connect() gets notifications/tools/list_changed, and sees the new list the next time it lists.

Where you register an adapter

Registered inIts entries are served
@App({ adapters })By that app
@Plugin({ adapters })By the app the plugin is on
@FrontMcp({ adapters })Once for the server, next to every app's entries. fetch() runs once, however many apps there are. Changed in 1.9: @FrontMcp ignored adapters.
create({ adapters })By the server's one app (create())

Names

An adapter's entries keep the names fetch() gives them. When two sources give a tool the same name, each is listed with its source in front: an adapter's with its options.name, the app's own tools with the app's id. Two copies of the saved-views adapter named tickets and kb, each with a view called search, list tickets:view_search and kb:view_search, and an app desk with a view_search tool of its own would list it as desk:view_search. See handling name clashes.

Caveats

  • init(options) constructs the adapter when your module is imported, not when the server starts. Use the useFactory form for options that need a provider or the environment.
  • init() names last for the process. A second init() of the same class with the same name throws, even for another server, or when a module that calls it is loaded twice. Call init() once at module level and reuse the record.
  • A record serves a fresh adapter in each server. The first server built from an init(options) record gets the adapter init() made. A second server from the same record, like a test that builds the server again, gets a new one, constructed from the same options, and runs its fetch() again. The useFactory form builds one per server. Don't keep state two servers must share on the adapter.
  • fetch() runs while the server starts, so the server isn't ready until it returns. A source that's slow or down delays or stops the start.

Usage

Writing an adapter

An adapter's job is to read a description and return entries. This one reads the help desk's saved views from its options and returns a tool for each. The tools are written with tool(), since there's one per view rather than one class each, and get the app's TicketStore with ctx.get():

Open
import { Adapter, DynamicAdapter, tool, type FrontMcpAdapterResponse } from "@frontmcp/sdk";
import { TicketStore, type TicketFilter } from "./ticket-store";

export interface SavedViewsOptions {
  views: { name: string; description: string; filter: TicketFilter }[];
}

@Adapter({ name: "saved-views", description: "A tool for each saved ticket view" })
export class SavedViewsAdapter extends DynamicAdapter<SavedViewsOptions> {
  options: { name: string } & SavedViewsOptions;

  constructor(options: { name: string } & SavedViewsOptions) {
    super();
    this.options = options;
  }

  fetch(): FrontMcpAdapterResponse {
    return {
      tools: this.options.views.map((view) =>
        tool({ name: `view_${view.name}`, description: view.description, inputSchema: {} })((_input, ctx) => ({
          tickets: ctx.get(TicketStore).find(view.filter),
        })),
      ),
    };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Add a view to the list in help-desk.app.ts and its tool appears. The app has no tool classes of its own: everything in tools/list came from fetch().

Adding resources and prompts

fetch() can return resources and prompts too, in the same response. Here the adapter also serves the list of views as a resource, so a client can show them, and a prompt that asks the model to work through one:

Open
import { Adapter, DynamicAdapter, prompt, resource, tool, type FrontMcpAdapterResponse } from "@frontmcp/sdk";
import { TicketStore, type TicketFilter } from "./ticket-store";

export interface SavedViewsOptions {
  views: { name: string; description: string; filter: TicketFilter }[];
}

@Adapter({ name: "saved-views", description: "Saved ticket views as tools, a resource and a prompt" })
export class SavedViewsAdapter extends DynamicAdapter<SavedViewsOptions> {
  options: { name: string } & SavedViewsOptions;

  constructor(options: { name: string } & SavedViewsOptions) {
    super();
    this.options = options;
  }

  fetch(): FrontMcpAdapterResponse {
    const { views } = this.options;
    return {
      tools: views.map((view) =>
        tool({ name: `view_${view.name}`, description: view.description, inputSchema: {} })((_input, ctx) => ({
          tickets: ctx.get(TicketStore).find(view.filter),
        })),
      ),
      resources: [
        resource({ name: "saved-views", uri: "views://saved", mimeType: "application/json", description: "The saved ticket views" })(
          (uri) => ({
            contents: [{ uri, mimeType: "application/json", text: JSON.stringify(views.map(({ name, description }) => ({ name, description }))) }],
          }),
        ),
      ],
      prompts: [
        prompt({
          name: "work_through_view",
          description: "Go through a saved view's tickets one by one",
          arguments: [{ name: "view", description: `One of: ${views.map((v) => v.name).join(", ")}`, required: true }],
        })((args) => ({
          messages: [
            { role: "user", content: { type: "text", text: `Call view_${args?.view}, then suggest a next step for each ticket, oldest first.` } },
          ],
        })),
      ],
    };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Building the options when the server starts

When the options come from a provider, like the help desk's configuration, use init({ name, inject, useFactory }). FrontMCP calls the factory while the server starts, with the providers inject lists, and builds the adapter from what it returns. The adapter class doesn't change:

Open
import { App } from "@frontmcp/sdk";
import { DeskConfig } from "./desk-config";
import { SavedViewsAdapter } from "./saved-views.adapter";
import { TicketStore } from "./ticket-store";

@App({
  id: "help-desk",
  name: "Help Desk",
  providers: [TicketStore, DeskConfig],
  adapters: [
    SavedViewsAdapter.init({
      name: "views",
      inject: () => [DeskConfig] as const,
      useFactory: (config: DeskConfig) => ({ views: config.savedViews }),
    }),
  ],
})
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The factory can be async, so it can read a file or call a service before the server takes requests.

Serving the entries from every app

An adapter in @FrontMcp({ adapters }) belongs to the server: its entries are listed once, next to every app's, and fetch() runs once. Its tools get the server's providers, so TicketStore moves to @FrontMcp({ providers }) here:

Open
import { App, FrontMcp, tool, z } from "@frontmcp/sdk";
import { SavedViewsAdapter } from "./saved-views.adapter";
import { TicketStore } from "./ticket-store";

const getTicket = tool({ name: "get_ticket", description: "Get one ticket by its id", inputSchema: { id: z.string() } })(
  ({ id }, ctx) => ({ ticket: ctx.get(TicketStore).find({}).find((t) => t.id === id) }),
);
const searchArticles = tool({ name: "search_articles", description: "Search help articles", inputSchema: { query: z.string() } })(
  ({ query }) => ({ articles: [`How to fix: ${query}`] }),
);

@App({ id: "tickets", name: "Tickets", tools: [getTicket] })
class TicketsApp {}

@App({ id: "kb", name: "Knowledge Base", tools: [searchArticles] })
class KnowledgeBaseApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [TicketsApp, KnowledgeBaseApp],
  providers: [TicketStore],
  adapters: [
    SavedViewsAdapter.init({ name: "views", views: [{ name: "all_open", description: "Every open ticket", filter: { status: "open" } }] }),
  ],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

To bring an adapter along with a plugin, list it in the plugin's own adapters: its entries join the app the plugin is on.

Updating the entries while the server runs

An adapter whose source changes can replace its entries without a restart. FrontMCP calls onUpdate() with a callback once fetch()'s entries are registered, then startPolling(); call the callback with a new response whenever the source changes. Here the saved views live in a ViewCatalog provider that a save_view tool writes to, and the adapter checks it every 100 ms. The call below saves a view, and the Tests tab shows its tool appear at the next check:

Open
import { Adapter, DynamicAdapter, tool, type FrontMcpAdapterResponse, type FrontMcpLogger } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";
import type { ViewCatalog } from "./view-catalog";

export interface LiveViewsOptions {
  catalog: ViewCatalog;
  everyMs: number;
}

type Listener = (response: FrontMcpAdapterResponse) => void;

@Adapter({ name: "live-views", description: "A tool for each saved view, kept up to date" })
export class LiveViewsAdapter extends DynamicAdapter<LiveViewsOptions> {
  options: { name: string } & LiveViewsOptions;
  private listeners = new Set<Listener>();
  private timer?: ReturnType<typeof setInterval>;
  private seen = -1;
  private logger?: FrontMcpLogger;

  constructor(options: { name: string } & LiveViewsOptions) {
    super();
    this.options = options;
  }

  setLogger(logger: FrontMcpLogger) {
    this.logger = logger;
  }

  fetch(): FrontMcpAdapterResponse {
    this.seen = this.options.catalog.revision;
    return this.build();
  }

  onUpdate(listener: Listener) {
    this.listeners.add(listener);
    return () => {
      this.listeners.delete(listener);
    };
  }

  startPolling() {
    this.timer = setInterval(() => {
      const { catalog } = this.options;
      if (catalog.revision === this.seen) return;
      this.seen = catalog.revision;
      this.logger?.info(`Saved views changed: ${catalog.views.length} now`);
      const response = this.build();
      for (const listener of this.listeners) listener(response);
    }, this.options.everyMs);
  }

  stopPolling() {
    clearInterval(this.timer);
  }

  private build(): FrontMcpAdapterResponse {
    return {
      tools: this.options.catalog.views.map((view) =>
        tool({ name: `view_${view.name}`, description: view.description, inputSchema: {} })((_input, ctx) => ({
          tickets: ctx.get(TicketStore).find(view.filter),
        })),
      ),
    };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The adapter's log line is labelled adapter:views, from the logger setLogger() was given. When the server is disposed, FrontMCP calls the function onUpdate() returned, then stopPolling(), which clears the timer. For a source on another service, check it from startPolling()'s timer the same way, and compare a version or an ETag rather than rebuilding every time. The OpenAPI adapter's polling works like this.

Publishing an adapter

An adapter other teams install is an npm package, set up like a plugin's: see Publishing a plugin. Export the class and its options type from the package's entry, and list @frontmcp/sdk as a peer dependency, so the adapter extends the app's own copy of DynamicAdapter. The keyword frontmcp-adapter lets people find it with npm search keywords:frontmcp-adapter; nothing in FrontMCP reads it.


Troubleshooting

Adapter ….init() requires a non-empty 'name' option

init() was called without a name, or with an empty one. Every copy needs its own: it's what the tools are listed under when names clash, and its logger's label.

Duplicate adapter name '…' for …

Two init() calls of the same class used the same name in one process. Give each copy its own name. With only one call in your code, init() ran twice: a module loaded twice, or a function that builds the configuration called again. Call init() once at module level and reuse the record. See Caveats.

@App invalid metadata for "adapters": adapters items must be annotated with @Adapter() | @FrontMcpAdapter()

Something in adapters isn't an adapter: a class without @Adapter, or a tool or a plugin. Add @Adapter({ name }) to the class, or move the entry to tools or plugins. It fails when the decorator runs, as your module is imported.

Invalid adapter '…'. Expected an adapter: an object with `options.name` and `fetch()`.

A DynamicAdapter was listed as its class, adapters: [SavedViewsAdapter], so its constructor got no options and options.name is missing. List SavedViewsAdapter.init({ name, ... }) instead.

Invalid adapter '…'. Expected useFactory to return the adapter's options object.

The factory of an init({ name, inject, useFactory }) record returned something other than an object, like undefined from a function that forgot to return. The server doesn't start.

Invalid adapter '…'. Expected useFactory to return an adapter named '…', the name given to init(), not '…'.

The factory built the adapter itself, with an options.name other than the one given to init(). Use the same name, or return the options and let FrontMCP build it.

Provider "…" is not available: not found in local or parent registries

A provider in inject isn't registered where the adapter is: add it to the providers of the same app, or of @FrontMcp for an adapter on the server.

TypeScript says Non-abstract class '…' does not implement inherited abstract member options

The class extends DynamicAdapter and doesn't declare options. Add options: { name: string } & Options; and set it in the constructor, as in the example.

The server doesn't start, with an error from my source

fetch() threw or rejected, and FrontMCP stops the start with that error. Catch what can fail and return fewer entries, or none, when the server should start anyway.

My adapter's tool is listed as views:view_…

Another adapter, or one of the app's own tools, has a tool of the same name, so each is listed with its source in front. Rename one. See names.