# @Adapter

> Write an adapter, a class whose fetch() builds tools, resources and prompts from another source when the server starts, and can replace them while it runs.

Source: https://frontmcp.dev/reference/sdk/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](https://frontmcp.dev/reference/adapters/openapi) is one, written this way. To add behaviour around every call rather than entries, write a [plugin](https://frontmcp.dev/reference/sdk/plugin).

```ts
@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:

```ts 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),
        })),
      ),
    };
  }
}
```

```ts 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.](#usage)

#### Options

| Option | Type | Description |
| --- | --- | --- |
| `name` | `string` | **Required.** Names the adapter class in FrontMCP's debug logs. The name that matters to clients is each copy's own, `options.name`: see [names](#names). |
| `description` | `string` | For people reading the code. Clients never see it. |
| `id` | `string` | Accepted 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()`](#app-invalid-metadata-for-adapters-adapters-items-must-be-annotated-with-adapter--frontmcpadapter).

#### `DynamicAdapter<Options>`

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

| Member | Description |
| --- | --- |
| `options` | **Required.** 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](#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](#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`](#invalid-adapter--expected-an-adapter-an-object-with-optionsname-and-fetch).

#### Optional methods

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

| Method | When | What to do |
| --- | --- | --- |
| `setLogger(logger)` | Before `fetch()` | Keep the logger. Its lines are labelled `adapter:<options.name>`. |
| `onUpdate(callback)` | After the entries from `fetch()` are registered | Keep `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 instance | Start watching your source, like a timer that checks it. |
| `stopPolling()` | When the server is disposed, after the function `onUpdate()` returned is called | Stop 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:

| Field | Holds |
| --- | --- |
| `tools` | [`@Tool`](https://frontmcp.dev/reference/sdk/tool) classes, or tools written with [`tool()`](https://frontmcp.dev/reference/sdk/tool#writing-a-tool-as-a-function) |
| `resources` | [`@Resource`](https://frontmcp.dev/reference/sdk/resource) classes, or [`resource()`](https://frontmcp.dev/reference/sdk/resource#writing-a-resource-as-a-function) |
| `prompts` | [`@Prompt`](https://frontmcp.dev/reference/sdk/prompt) classes, or [`prompt()`](https://frontmcp.dev/reference/sdk/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()`](https://frontmcp.dev/reference/sdk/connect) gets `notifications/tools/list_changed`, and sees the new list the next time it lists.

#### Where you register an adapter

| Registered in | Its 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()`](https://frontmcp.dev/reference/sdk/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](https://frontmcp.dev/reference/server/apps#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()`:

```ts saved-views.adapter.ts active
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),
        })),
      ),
    };
  }
}
```

```ts 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" } },
        { name: "all_open", description: "Every open ticket", filter: { status: "open" } },
      ],
    }),
  ],
})
export class HelpDeskApp {}
```

```ts ticket-store.ts
import { Provider } from "@frontmcp/sdk";

export interface TicketFilter {
  status?: "open" | "closed";
  tag?: string;
}

@Provider({ name: "TicketStore" })
export class TicketStore {
  private tickets = [
    { id: "T-1", title: "Cannot log in", status: "open", tag: "login" },
    { id: "T-2", title: "Invoice total is wrong", status: "closed", tag: "billing" },
    { id: "T-3", title: "Charged twice", status: "open", tag: "billing" },
  ];

  find(filter: TicketFilter) {
    return this.tickets.filter(
      (t) => (!filter.status || t.status === filter.status) && (!filter.tag || t.tag === filter.tag),
    );
  }
}
```

```ts saved-views.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { SavedViewsAdapter } from "./saved-views.adapter";
import { TicketStore } from "./ticket-store";

test("each saved view is a tool, described as the view is", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools.map((t: { name: string }) => t.name).sort()).toEqual(["view_all_open", "view_open_billing"]);
  expect(tools.find((t: { name: string }) => t.name === "view_open_billing")?.description).toBe("Open billing tickets");
});

test("a view's tool gets the app's TicketStore", async ({ mcp }) => {
  const result = await mcp.tools.call("view_all_open", {});
  expect(result).toBeSuccessful();
  expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-1", "T-3"]);
});

test("two copies with the same tool name are listed under their names", async () => {
  const view = { name: "search", description: "Open tickets", filter: { status: "open" as const } };
  @App({
    id: "desk",
    name: "Desk",
    providers: [TicketStore],
    adapters: [
      SavedViewsAdapter.init({ name: "tickets", views: [view] }),
      SavedViewsAdapter.init({ name: "kb", views: [view] }),
    ],
  })
  class Desk {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "desk", version: "1.0.0" }, apps: [Desk] });
  try {
    const { tools } = await server.listTools();
    expect(tools.map((t: { name: string }) => t.name).sort()).toEqual(["kb:view_search", "tickets:view_search"]);
  } finally {
    await server.dispose();
  }
});
```

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:

```ts saved-views.adapter.ts active
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.` } },
          ],
        })),
      ],
    };
  }
}
```

```ts 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" } },
        { name: "all_open", description: "Every open ticket", filter: { status: "open" } },
      ],
    }),
  ],
})
export class HelpDeskApp {}
```

```ts ticket-store.ts
import { Provider } from "@frontmcp/sdk";

export interface TicketFilter {
  status?: "open" | "closed";
  tag?: string;
}

@Provider({ name: "TicketStore" })
export class TicketStore {
  private tickets = [
    { id: "T-1", title: "Cannot log in", status: "open", tag: "login" },
    { id: "T-2", title: "Invoice total is wrong", status: "closed", tag: "billing" },
    { id: "T-3", title: "Charged twice", status: "open", tag: "billing" },
  ];

  find(filter: TicketFilter) {
    return this.tickets.filter(
      (t) => (!filter.status || t.status === filter.status) && (!filter.tag || t.tag === filter.tag),
    );
  }
}
```

```ts saved-views.test.ts
import { test, expect } from "@frontmcp/testing";

test("the views are a resource", async ({ mcp }) => {
  expect(await mcp.resources.list()).toContainResource("views://saved");
  const views = (await mcp.resources.read("views://saved")).json();
  expect(views.map((v: { name: string }) => v.name)).toEqual(["open_billing", "all_open"]);
});

test("the prompt names the view's tool", async ({ mcp }) => {
  expect(await mcp.prompts.list()).toContainPrompt("work_through_view");
  const result = await mcp.prompts.get("work_through_view", { view: "open_billing" });
  expect(result).toHaveMessages(1);
  expect(result.messages[0].content).toMatchObject({ text: expect.stringContaining("Call view_open_billing") });
});
```

### 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:

```ts help-desk.app.ts active
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 {}
```

```ts desk-config.ts
import { Provider } from "@frontmcp/sdk";
import type { SavedViewsOptions } from "./saved-views.adapter";

@Provider({ name: "DeskConfig" })
export class DeskConfig {
  // In a real server, read from a file or a database
  savedViews: SavedViewsOptions["views"] = [
    { name: "vip_open", description: "Open tickets from VIP customers", filter: { status: "open", tag: "vip" } },
  ];
}
```

```ts 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),
        })),
      ),
    };
  }
}
```

```ts ticket-store.ts
import { Provider } from "@frontmcp/sdk";

export interface TicketFilter {
  status?: "open" | "closed";
  tag?: string;
}

@Provider({ name: "TicketStore" })
export class TicketStore {
  private tickets = [
    { id: "T-1", title: "Cannot log in", status: "open", tag: "login" },
    { id: "T-4", title: "Export is empty", status: "open", tag: "vip" },
  ];

  find(filter: TicketFilter) {
    return this.tickets.filter(
      (t) => (!filter.status || t.status === filter.status) && (!filter.tag || t.tag === filter.tag),
    );
  }
}
```

```ts desk-config.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { SavedViewsAdapter } from "./saved-views.adapter";

test("the views come from DeskConfig", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("view_vip_open");
  const result = await mcp.tools.call("view_vip_open", {});
  expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-4"]);
});

test("a factory that returns something else stops the server", async () => {
  @App({
    id: "desk",
    name: "Desk",
    adapters: [SavedViewsAdapter.init({ name: "broken-views", useFactory: () => undefined as never })],
  })
  class Desk {}
  await expect(
    FrontMcpInstance.createDirect({ info: { name: "desk", version: "1.0.0" }, apps: [Desk] }),
  ).rejects.toThrow("Invalid adapter 'broken-views'. Expected useFactory to return the adapter's options object.");
});
```

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:

```ts main.ts active
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 {}
```

```ts 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),
        })),
      ),
    };
  }
}
```

```ts ticket-store.ts
import { Provider } from "@frontmcp/sdk";

export interface TicketFilter {
  status?: "open" | "closed";
  tag?: string;
}

@Provider({ name: "TicketStore" })
export class TicketStore {
  private tickets = [
    { id: "T-1", title: "Cannot log in", status: "open", tag: "login" },
    { id: "T-2", title: "Invoice total is wrong", status: "closed", tag: "billing" },
    { id: "T-3", title: "Charged twice", status: "open", tag: "billing" },
  ];

  find(filter: TicketFilter) {
    return this.tickets.filter(
      (t) => (!filter.status || t.status === filter.status) && (!filter.tag || t.tag === filter.tag),
    );
  }
}
```

```ts server.test.ts
import { test, expect } from "@frontmcp/testing";

test("the adapter's tool is listed once, next to both apps' tools", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name).sort();
  expect(names).toEqual(["get_ticket", "search_articles", "view_all_open"]);
});

test("it gets the server's TicketStore", async ({ mcp }) => {
  const result = await mcp.tools.call("view_all_open", {});
  expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-1", "T-3"]);
});
```

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:

```ts live-views.adapter.ts active
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),
        })),
      ),
    };
  }
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { LiveViewsAdapter } from "./live-views.adapter";
import { TicketStore } from "./ticket-store";
import { ViewCatalog } from "./view-catalog";

@Tool({
  name: "save_view",
  description: "Save a ticket view. It becomes a tool named view_<name>.",
  inputSchema: {
    name: z.string().regex(/^[a-z_]+$/).describe("Lowercase words joined by _, like open_billing"),
    description: z.string(),
    status: z.enum(["open", "closed"]).optional(),
    tag: z.string().optional(),
  },
})
export class SaveView extends ToolContext {
  async execute({ name, description, status, tag }: { name: string; description: string; status?: "open" | "closed"; tag?: string }) {
    this.get(ViewCatalog).save({ name, description, filter: { status, tag } });
    return { saved: `view_${name}` };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [SaveView],
  providers: [TicketStore, ViewCatalog],
  adapters: [
    LiveViewsAdapter.init({
      name: "views",
      inject: () => [ViewCatalog] as const,
      useFactory: (catalog: ViewCatalog) => ({ catalog, everyMs: 100 }),
    }),
  ],
})
export class HelpDeskApp {}
```

```ts view-catalog.ts
import { Provider } from "@frontmcp/sdk";
import type { TicketFilter } from "./ticket-store";

export interface SavedView {
  name: string;
  description: string;
  filter: TicketFilter;
}

@Provider({ name: "ViewCatalog" })
export class ViewCatalog {
  revision = 0;
  views: SavedView[] = [{ name: "all_open", description: "Every open ticket", filter: { status: "open" } }];

  save(view: SavedView) {
    this.views = [...this.views.filter((v) => v.name !== view.name), view];
    this.revision++;
  }
}
```

```ts ticket-store.ts
import { Provider } from "@frontmcp/sdk";

export interface TicketFilter {
  status?: "open" | "closed";
  tag?: string;
}

@Provider({ name: "TicketStore" })
export class TicketStore {
  private tickets = [
    { id: "T-1", title: "Cannot log in", status: "open", tag: "login" },
    { id: "T-2", title: "Invoice total is wrong", status: "closed", tag: "billing" },
    { id: "T-3", title: "Charged twice", status: "open", tag: "billing" },
  ];

  find(filter: TicketFilter) {
    return this.tickets.filter(
      (t) => (!filter.status || t.status === filter.status) && (!filter.tag || t.tag === filter.tag),
    );
  }
}
```

```ts live-views.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";

const nextCheck = () => new Promise((resolve) => setTimeout(resolve, 250));

test("a saved view becomes a tool at the next check", async ({ mcp }) => {
  expect(await mcp.tools.list()).not.toContainTool("view_open_billing");
  await mcp.tools.call("save_view", { name: "open_billing", description: "Open billing tickets", status: "open", tag: "billing" });
  await nextCheck();
  expect(await mcp.tools.list()).toContainTool("view_open_billing");
  const result = await mcp.tools.call("view_open_billing", {});
  expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-3"]);
});

test("a connected client hears that the tools changed", async () => {
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  const client = await server.connect();
  const heard: string[] = [];
  client.onNotification((notification: { method: string }) => {
    heard.push(notification.method);
  });
  try {
    await client.callTool("save_view", { name: "vip", description: "VIP tickets", tag: "vip" });
    await nextCheck();
    expect(heard).toContain("notifications/tools/list_changed");
  } finally {
    await client.close();
    await server.dispose();
  }
});
```

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`](https://frontmcp.dev/reference/adapters/openapi#picking-up-spec-changes) 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](https://frontmcp.dev/reference/sdk/plugin#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](#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](#adapteroptions).

### 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](#names).
