# Built-in Plugins and Adapters

> The plugins and the adapter the FrontMCP team publishes, and when to reach for each. Caching results, remembering values across calls, asking for approval, feature flags, CodeCall, and turning an OpenAPI spec into tools.

Source: https://frontmcp.dev/learn/built-in-plugins-and-adapters

In [Extending with Plugins](https://frontmcp.dev/learn/extending-with-plugins) you wrote a plugin that records every change in an audit log. Some jobs come up on almost every server, and for those the FrontMCP team publishes plugins of its own: answering a repeated lookup without running it again, remembering a user's preference, making sure a person agreed before something is deleted, shipping a tool to some users before the rest. There's also CodeCall, for servers with more tools than a model can read, and an adapter that turns an OpenAPI description of a REST API into tools. This chapter teaches each one with the help desk, and points out where FrontMCP 1.9.2 behaves differently from what an option's name suggests.

**In this chapter**
- [How to answer repeated calls from a cache](https://frontmcp.dev/learn/caching-results)
- [How tools remember values from one call to the next](https://frontmcp.dev/learn/remembering-across-calls)
- [How to require a person's approval before a tool runs](https://frontmcp.dev/learn/asking-for-approval)
- [How to turn tools on and off with feature flags](https://frontmcp.dev/learn/turning-features-on-and-off)
- [How to let the model find tools and call them from a short script](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall)
- [How to turn an OpenAPI spec into tools](https://frontmcp.dev/learn/wrapping-an-openapi-service)

## Using an official plugin

Each is its own npm package. The plugins are registered like the one you wrote, with `SomePlugin.init({ ... })` in the `plugins` of an `@App`, for that app's tools, or of `@FrontMcp`, for every app's, and each adds something your code opts in to. What a plugin adds to `this`, like `this.remember`, reaches only the tools of the apps it's registered for:

| Package | Adds | Lesson |
| --- | --- | --- |
| `@frontmcp/plugin-cache` | `cache` on `@Tool` | [Caching Results](https://frontmcp.dev/learn/caching-results) |
| `@frontmcp/plugin-remember` | `this.remember`, and four memory tools you can turn on | [Remembering Across Calls](https://frontmcp.dev/learn/remembering-across-calls) |
| `@frontmcp/plugin-approval` | `approval` on `@Tool`, and `this.approval` | [Asking for Approval](https://frontmcp.dev/learn/asking-for-approval) |
| `@frontmcp/plugin-feature-flags` | `featureFlag` on tools, resources, prompts, skills and agents, and `this.featureFlags` | [Turning Features On and Off](https://frontmcp.dev/learn/turning-features-on-and-off) |
| `@frontmcp/plugin-codecall` | Six `codecall:*` tools in front of yours | [Letting the Model Write Code with CodeCall](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall) |
| `@frontmcp/adapters` | `OpenapiAdapter`: a tool for each operation in a spec | [Wrapping an OpenAPI Service](https://frontmcp.dev/learn/wrapping-an-openapi-service) |

A few rules hold for all of them:

- **Register a plugin with `init()`.** With no argument it's the same as `init({})`, the defaults, for the plugins whose options all have defaults. Feature flags need an `adapter`: `FeatureFlagPlugin.init()` throws a `FeatureFlagConfigurationError` without it. The bare class, `plugins: [SomePlugin]`, is the same as `init()` with no argument, so `plugins: [FeatureFlagPlugin]` stops the server with that error. Before 1.9.4 the bare class worked for the cache plugin only.
- **`approval` and `featureFlag` need their plugin.** A server with a tool that declares one of them, and no plugin to enforce it, refuses to start, so a gate can't be left off by accident. `cache` and `codecall` without their plugin do nothing.
- **Adapters go in `adapters`**, not in `plugins`: an app's, for that app, or `@FrontMcp`'s, whose adapter tools every app serves.
- **Every package loads in CommonJS and ES module projects alike.** Before 1.9, an ES module project (`"type": "module"`) couldn't import the cache and CodeCall plugins: see [ES module projects](https://frontmcp.dev/reference/plugins#es-module-projects).

## Caching results

A lookup the model repeats, like a customer's account from a slow CRM, doesn't need to run twice. With the cache plugin on the app and `cache: { ttl }` on the tool, the next call with the same arguments is answered from a store, without running `execute()`:

```ts help-desk.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { crm } from "./crm";

@Tool({
  name: "get_customer",
  description: "Get a customer account by its id. May be up to 5 minutes old.",
  inputSchema: { id: z.string().describe("Customer id, like C-1") },
  annotations: { readOnlyHint: true },
  cache: { ttl: 300 }, // seconds
})
export class GetCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return crm.getCustomer(id);
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetCustomer],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], // the same answer for every caller
})
export class HelpDeskApp {}
```

```ts crm.ts
// Stands in for a CRM that is slow to answer, and counts the requests it gets.
export const crm = {
  requests: 0,
  async getCustomer(id: string) {
    crm.requests++;
    await new Promise((resolve) => setTimeout(resolve, 200));
    return { id, name: id === "C-1" ? "Acme Corp" : "Globex", plan: "enterprise" };
  },
};
```

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

test("the second call is answered from the cache", async ({ mcp }) => {
  const before = crm.requests;
  await mcp.tools.call("get_customer", { id: "C-2" });
  const second = await mcp.tools.call("get_customer", { id: "C-2" });
  expect(second.raw._meta?.cache).toBe("hit");
  expect(crm.requests - before).toBe(1);
});
```

By default each caller has entries of their own. That keeps one user's answer from reaching another, and it also means an anonymous caller under MCP 2026-07-28, who is new on every request, is never answered from the cache. `keyByIdentity: false` shares entries, and is only safe for answers that are the same for everyone.

Read more: [Caching Results](https://frontmcp.dev/learn/caching-results)
Learn how to turn the cache on, why it answers nothing for anonymous callers, how to keep per-caller answers apart and share the rest, what makes two calls the same, how long an entry lives and why it can't be cleared, and what not to cache.

## Remembering across calls

A tool forgets everything when its call ends. The Remember plugin gives every tool `this.remember`, a key-value memory whose **scope** decides who reads a value back. `user` scope keeps a value for the caller. The tests call the server in-process with [`createDirect()`](https://frontmcp.dev/learn/running-frontmcp-anywhere) as two signed-in agents, and as the Playground's anonymous client, which `user` scope refuses:

```ts signature.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { RememberPlugin } from "@frontmcp/plugin-remember";

@Tool({ name: "set_signature", description: "Set the signature your replies end with.", inputSchema: { signature: z.string() } })
export class SetSignature extends ToolContext {
  async execute({ signature }: { signature: string }) {
    await this.remember.set("signature", signature, { scope: "user" });
    return { signature };
  }
}

@Tool({ name: "draft_reply", description: "Draft a reply, signed with your signature.", inputSchema: { ticketId: z.string(), text: z.string() } })
export class DraftReply extends ToolContext {
  async execute({ ticketId, text }: { ticketId: string; text: string }) {
    const signature = await this.remember.get("signature", { scope: "user", defaultValue: "The Help Desk" });
    return { ticketId, reply: `${text}\n\n${signature}` };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [SetSignature, DraftReply], plugins: [RememberPlugin.init({ type: "memory" })] })
export class HelpDeskApp {}
```

```ts remember.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDeskApp } from "./signature.app";

test("each signed-in agent's signature is their own", async () => {
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  const nour = { authContext: { user: { sub: "nour" } } };
  const sam = { authContext: { user: { sub: "sam" } } };
  await server.callTool("set_signature", { signature: "Nour, Tier 2 support" }, nour);
  const nours = await server.callTool("draft_reply", { ticketId: "T-1", text: "Fixed." }, nour);
  const sams = await server.callTool("draft_reply", { ticketId: "T-2", text: "Fixed." }, sam);
  expect(nours.structuredContent).toMatchObject({ reply: "Fixed.\n\nNour, Tier 2 support" });
  expect(sams.structuredContent).toMatchObject({ reply: "Fixed.\n\nThe Help Desk" });
  await server.dispose();
});

test("an anonymous caller is refused user scope, with a message that says why", async ({ mcp }) => {
  const result = await mcp.tools.call("set_signature", { signature: "Someone" });
  expect(result).toBeError("REMEMBER_IDENTITY_REQUIRED");
  expect(result).toHaveTextContent("Remember cannot use user scope without an authenticated user");
});
```

The other scopes matter as much: `global` is shared by everyone, and `session`, the default, doesn't mean a conversation under MCP 2026-07-28. It lasts for the signed-in caller. An anonymous caller can't use `user` or `session`: Remember refuses it with `REMEMBER_IDENTITY_REQUIRED`, and only `global` works for it.

Read more: [Remembering Across Calls](https://frontmcp.dev/learn/remembering-across-calls)
Learn how to store and read values with `this.remember`, what each scope keeps for signed-in and anonymous callers under MCP 2026-07-28, how to make values expire and forget them, and how to give the model memory tools of its own, limited to the scopes you choose.

## Asking for approval

`destructiveHint` tells a client a tool can't be undone, and the client may still run it without asking anyone. The approval plugin refuses every call to a tool with `approval` until an approval of it is recorded for the caller:

```ts help-desk.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";

export const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong" }],
]);

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good. Needs the user's approval: call approve_delete first.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
  approval: { approvalMessage: "delete_ticket needs the user's approval. Call approve_delete first." },
})
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: tickets.delete(id) };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [DeleteTicket], plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })] })
export class HelpDeskApp {}
```

```ts approval.test.ts
import { test, expect } from "@frontmcp/testing";
import { tickets } from "./help-desk.app";

test("an unapproved delete is refused, and the ticket stays", async ({ mcp }) => {
  const result = await mcp.tools.call("delete_ticket", { id: "T-2" });
  expect(result).toBeError();
  expect(result.text()).toMatch(/^delete_ticket needs the user's approval/);
  expect(tickets.has("T-2")).toBe(true);
});
```

The plugin doesn't ask anyone itself. Your code records an approval, with `this.approval`, once the user says yes in a form from `this.elicit()`, which under MCP 2026-07-28 the model can't answer. An approval belongs to a signed-in caller, so an anonymous caller can never keep one.

Read more: [Asking for Approval](https://frontmcp.dev/learn/asking-for-approval)
Learn how to put a tool behind approval, what the model sees when a call is refused, which is the same in production, how to record an approval only after the user says yes, and how to withdraw one, limit how long it lasts, and why a session approval lasts for the user under MCP 2026-07-28.

## Turning features on and off

A feature flag ships a tool to some users before the rest, or turns one off without a deploy. With the feature flags plugin, `featureFlag: "bulk-export"` on a tool hides it from every list while the flag is off for the caller, and refuses it if it's called anyway. Flags come from an adapter: fixed values, as here, or LaunchDarkly, Split, Unleash or your own. Open the **Capabilities** tab to see which tools are listed:

```ts help-desk.app.ts active
import { App, Tool, ToolContext } from "@frontmcp/sdk";
import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags";

@Tool({ name: "search_tickets", description: "Search support tickets.", inputSchema: {} })
export class SearchTickets extends ToolContext {
  async execute() {
    return { tickets: ["T-1", "T-3"] };
  }
}

@Tool({ name: "bulk_export", description: "Export every ticket as CSV.", inputSchema: {}, featureFlag: "bulk-export" })
export class BulkExport extends ToolContext {
  async execute() {
    return { csv: "id,title\nT-1,Cannot log in" };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [SearchTickets, BulkExport],
  plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: { "bulk-export": false } })],
})
export class HelpDeskApp {}
```

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

test("a tool whose flag is off isn't listed", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t: { name: string }) => t.name)).toEqual(["search_tickets"]);
});

test("and is refused if it's called anyway", async ({ mcp }) => {
  const result = await mcp.tools.call("bulk_export", {});
  expect(result).toBeError("FEATURE_FLAG_DISABLED");
  expect(result).toHaveTextContent('Tool "bulk_export" is disabled by feature flag "bulk-export"');
});
```

Change `"bulk-export": false` to `true`, and `bulk_export` appears. Each caller's flags are evaluated for them, from their token, so a flag can be on for some users and off for the rest; under MCP 2026-07-28 every anonymous caller gets the same answer.

Read more: [Turning Features On and Off](https://frontmcp.dev/learn/turning-features-on-and-off)
Learn why a switch inside `execute()` is the wrong way to hide a tool, how to put tools, resources and prompts behind a flag, what a caller sees while it's off, how to make a flag a kill switch that's on by default, and how to turn a flag on for some callers first and read it inside a tool.

## Letting the model write code with CodeCall

A server with dozens of tools sends every tool's schema to the model before it can pick one. CodeCall puts them behind six meta-tools instead: the model searches with `codecall:search`, reads the schemas of the tools it picked with `codecall:describe`, and calls them one at a time with `codecall:invoke`, or several at once from a short script with `codecall:execute`:

```ts help-desk.app.ts active
import { App } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { CloseTicket, GetCustomer, GetTicket, SearchTickets } from "./tools";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [SearchTickets, GetTicket, CloseTicket, GetCustomer],
  plugins: [CodeCallPlugin.init({ mode: "codecall_only" })],
})
export class HelpDeskApp {}
```

```ts tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open", priority: "high", customerId: "C-1" },
  { id: "T-2", title: "Invoice total is wrong", status: "open", priority: "low", customerId: "C-2" },
  { id: "T-3", title: "Login link expired", status: "open", priority: "high", customerId: "C-2" },
];

const customers = [
  { id: "C-1", name: "Acme Corp", plan: "enterprise" },
  { id: "C-2", name: "Globex", plan: "starter" },
];

@Tool({
  name: "search_tickets",
  description: "Search support tickets by status",
  inputSchema: { status: z.enum(["open", "closed"]).optional() },
})
export class SearchTickets extends ToolContext {
  async execute({ status }: { status?: "open" | "closed" }) {
    return { tickets: tickets.filter((t) => !status || t.status === status) };
  }
}

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string().describe("A ticket id, like T-1") } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}

@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" };
  }
}

@Tool({ name: "get_customer", description: "Get a customer account by its id", inputSchema: { id: z.string() } })
export class GetCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return customers.find((c) => c.id === id) ?? this.fail(new PublicMcpError(`There's no customer ${id}.`));
  }
}
```

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

test("tools/list shows CodeCall's tools instead of the app's", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name);
  expect(names).toEqual([
    "codecall:describe",
    "codecall:execute",
    "codecall:invoke",
    "codecall:search",
    "codecall:searchKnowledge",
    "codecall:searchSkills",
  ]);
});

test("the model finds a tool by what it wants to do", async ({ mcp }) => {
  const { tools } = (await mcp.tools.call("codecall:search", { queries: ["close a ticket"] })).json();
  expect(tools[0]).toMatchObject({ name: "close_ticket", appId: "help-desk" });
});

test("and calls it through codecall:invoke", async ({ mcp }) => {
  const result = await mcp.tools.call("codecall:invoke", { tool: "get_ticket", input: { id: "T-1" } });
  expect(result.json()).toMatchObject({ id: "T-1", title: "Cannot log in" });
});

test("a script joins tools, and only its answer goes back", async ({ mcp }) => {
  const script = [
    'const { tickets } = await callTool("search_tickets", { status: "open" });',
    'const urgent = tickets.filter((t) => t.priority === "high");',
    "const out = [];",
    "for (const t of urgent) {",
    '  const customer = await callTool("get_customer", { id: t.customerId });',
    "  out.push({ ticket: t.id, customer: customer.name });",
    "}",
    "return out;",
  ].join("\n");
  const answer = (await mcp.tools.call("codecall:execute", { script })).json();
  expect(answer).toEqual({
    status: "ok",
    result: [
      { ticket: "T-1", customer: "Acme Corp" },
      { ticket: "T-3", customer: "Globex" },
    ],
  });
});
```

A script runs on the server, in a sandbox, and can loop over results and join them, so only the answer goes back to the model. This one lists the urgent open tickets with their customers, in one request:

```js
const { tickets } = await callTool("search_tickets", { status: "open" });
const urgent = tickets.filter((t) => t.priority === "high");
const out = [];
for (const t of urgent) {
  const customer = await callTool("get_customer", { id: t.customerId });
  out.push({ ticket: t.id, customer: customer.name });
}
return out;
```

Here are the tools above again, with `codecall:execute` called with that script. The Playground runs it in a browser sandbox, where a server uses one built on Node's `vm`; the answer is the same:

`codecall:execute` returns `{ "status": "ok", "result": [{ "ticket": "T-1", "customer": "Acme Corp" }, { "ticket": "T-3", "customer": "Globex" }] }`.

Read more: [Letting the Model Write Code with CodeCall](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall)
Learn what a long tool list costs a model, how to put your tools behind CodeCall and when it's worth it, how the model finds, reads and calls them, one at a time or from a script, what a script may do, and how to keep tools out of its reach.

## Wrapping an OpenAPI service

When what the model needs already exists as a REST API with an OpenAPI description, `OpenapiAdapter` from `@frontmcp/adapters` makes a tool of each operation, named by its `operationId`, which sends the request the spec describes and hands back the API's answer. The Playground can't reach a real API, so `desk.example.ts` plays it, in-process:

```ts desk.app.ts active
import "./desk.example";
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

@App({
  id: "desk",
  name: "Desk",
  adapters: [OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec })],
})
export class DeskApp {}
```

```ts spec.ts
export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  paths: {
    "/tickets": {
      get: {
        operationId: "listTickets",
        summary: "List support tickets, newest first",
        parameters: [{ name: "status", in: "query", schema: { type: "string", enum: ["open", "closed"] } }],
        responses: { "200": { description: "The tickets" } },
      },
    },
    "/tickets/{id}": {
      get: {
        operationId: "getTicket",
        summary: "Get one support ticket by its id",
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
        responses: { "200": { description: "The ticket" } },
      },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
// It answers requests in-process and records them. Your server doesn't need it.
export const received: string[] = [];

const tickets: Record<string, { id: string; title: string; status: string }> = {
  "T-1": { id: "T-1", title: "Cannot log in", status: "open" },
};

async function desk(request: Request): Promise<Response> {
  received.push(`${request.method} ${request.url}`);
  const path = new URL(request.url).pathname;
  if (path === "/v1/tickets") return Response.json(Object.values(tickets));
  const ticket = tickets[path.replace("/v1/tickets/", "")];
  return ticket ? Response.json(ticket) : Response.json({ message: "No such ticket" }, { status: 404 });
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  return url.hostname === "desk.example" ? desk(new Request(input, init)) : realFetch(input, init);
};
```

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

test("each operation is a tool named by its operationId", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t: { name: string }) => t.name).sort()).toEqual(["getTicket", "listTickets"]);
});

test("a call sends the request, and returns the API's status and body", async ({ mcp }) => {
  const result = await mcp.tools.call("getTicket", { id: "T-1" });
  expect(result.json()).toEqual({ status: 200, ok: true, data: { id: "T-1", title: "Cannot log in", status: "open" } });
  expect(received.at(-1)).toBe("GET https://desk.example/v1/tickets/T-1");
});

test("an error from the API is an error the model can read", async ({ mcp }) => {
  const result = await mcp.tools.call("getTicket", { id: "T-9" });
  expect(result).toBeError();
  expect(result.text()).toContain("No such ticket");
});
```

The model gets the API's answer as it is, and every operation in the spec as a tool, which is a lot of tools for a large API. The adapter's options choose which operations become tools, rename and describe them, send the API's credentials, and reshape what comes back. The adapter goes in an app's `adapters`, and each one needs a `name` of its own.

Read more: [Wrapping an OpenAPI Service](https://frontmcp.dev/learn/wrapping-an-openapi-service)
Learn how to turn an OpenAPI description into tools, what a call sends to the API and what the model gets back, how to give the adapter the API's credentials without the model seeing them, how to choose and name the operations the model sees, and what to do when an API is too big for one tool per operation.

## Plugin, adapter or your own code?

| If you need… | Use |
| --- | --- |
| The same answer again, without the wait | The cache plugin, on tools that only read |
| A value that outlasts one call | The Remember plugin, in the scope that matches who should read it |
| A person's yes before a tool runs | The approval plugin, with a form only the user can answer |
| A tool for some users, or one you can switch off | The feature flags plugin |
| More tools than a model can usefully read | CodeCall |
| An existing REST API as tools | The OpenAPI adapter, or a few tools with [`this.fetch()`](https://frontmcp.dev/learn/calling-other-services) when the model only needs a few well-shaped calls |
| Something none of these do | [A plugin of your own](https://frontmcp.dev/learn/extending-with-plugins) |

Every option of each one is in the [plugins and adapters reference](https://frontmcp.dev/reference/plugins).

## What's next?

Start the chapter with [Caching Results](https://frontmcp.dev/learn/caching-results). After it, [Testing and Shipping](https://frontmcp.dev/learn/testing-and-shipping) covers proving your server works and getting it into production.
