# Reporting Progress and Logs

> How a long-running tool tells the client how far along it is and what it's doing, with this.progress() and this.notify(), and how clients opt in under MCP 2026-07-28.

Source: https://frontmcp.dev/learn/reporting-progress

A tool that takes ten seconds looks exactly like a tool that's stuck, until it returns. `this.progress()` and `this.notify()` let a tool report while it works: how much is done, and anything worth knowing along the way. The client can show a progress bar, or a line in a log, and the person waiting knows the call is still alive.

**You will learn**
- How to report progress with `this.progress()`
- How to send log messages with `this.notify()`, and which level to use
- How a client asks for notifications under MCP 2026-07-28, and what the response looks like
- What happens when a client doesn't ask
- How to test progress and logs

## A tool that goes quiet

This tool exports every ticket to a CSV file, one ticket at a time. Each ticket takes a moment:

```ts export-tickets.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";
import { exportRow, tickets } from "./store";

@Tool({
  name: "export_tickets",
  description: "Export every support ticket to tickets.csv. Takes a few seconds.",
  inputSchema: {},
})
export class ExportTickets extends ToolContext {
  async execute() {
    const rows: string[] = [];
    for (const ticket of tickets) {
      rows.push(await exportRow(ticket));
    }
    return { file: "tickets.csv", rows: rows.length };
  }
}
```

```ts store.ts
export const tickets = [
  { id: "T-1", title: "Cannot log in" },
  { id: "T-2", title: "" },
  { id: "T-3", title: "Login link expired" },
  { id: "T-4", title: "Password reset email missing" },
  { id: "T-5", title: "Invoice total is wrong" },
];

/** Pretend each ticket takes a moment to export. */
export async function exportRow(ticket: { id: string; title: string }) {
  await new Promise((resolve) => setTimeout(resolve, 150));
  return `${ticket.id},"${ticket.title}"`;
}
```

With five tickets that's under a second. With five thousand, the user stares at a spinner for minutes and can't tell a slow export from a stuck one. Nothing about this tool is wrong, except that it says nothing until it's done.

## Reporting progress

`this.progress(progress, total, message)` sends a progress notification to the client that made the call:

- `progress` is how much is done so far. It should go up with every call; the MCP spec requires it.
- `total` is how much there is in all, if you know it. Leave it out if you don't.
- `message` is a short description of the current step. It's optional too.

```ts export-tickets.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";
import { exportRow, tickets } from "./store";

@Tool({
  name: "export_tickets",
  description: "Export every support ticket to tickets.csv. Takes a few seconds.",
  inputSchema: {},
})
export class ExportTickets extends ToolContext {
  async execute() {
    const rows: string[] = [];
    for (const [i, ticket] of tickets.entries()) {
      rows.push(await exportRow(ticket));
      await this.progress(i + 1, tickets.length, `Exported ${ticket.id}`);
    }
    return { file: "tickets.csv", rows: rows.length };
  }
}
```

```ts store.ts
export const tickets = [
  { id: "T-1", title: "Cannot log in" },
  { id: "T-2", title: "" },
  { id: "T-3", title: "Login link expired" },
  { id: "T-4", title: "Password reset email missing" },
  { id: "T-5", title: "Invoice total is wrong" },
];

/** Pretend each ticket takes a moment to export. */
export async function exportRow(ticket: { id: string; title: string }) {
  await new Promise((resolve) => setTimeout(resolve, 150));
  return `${ticket.id},"${ticket.title}"`;
}
```

The Call tab now shows a progress bar and five `progress` notifications above the result. A real client gets each one as it's sent, while the tool is still running. The Playground collects the whole response first, so here they appear together when the call ends.

Report after each real unit of work, like a ticket, a page or a file. Progress that jumps from 0 to 100% at the end doesn't tell anyone anything.

## Sending log messages

Progress says how far along a tool is. `this.notify(message, level)` says what's happening, as a log message:

```ts export-tickets.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";
import { exportRow, tickets } from "./store";

@Tool({
  name: "export_tickets",
  description: "Export every support ticket to tickets.csv. Takes a few seconds.",
  inputSchema: {},
})
export class ExportTickets extends ToolContext {
  async execute() {
    await this.notify(`Exporting ${tickets.length} tickets to tickets.csv`);

    const rows: string[] = [];
    for (const [i, ticket] of tickets.entries()) {
      if (!ticket.title) await this.notify(`${ticket.id} has no title. It's exported with an empty one.`, "warning");
      rows.push(await exportRow(ticket));
      await this.progress(i + 1, tickets.length, `Exported ${ticket.id}`);
    }

    await this.notify({ file: "tickets.csv", bytes: rows.join("\n").length }, "debug");
    return { file: "tickets.csv", rows: rows.length };
  }
}
```

```ts store.ts
export const tickets = [
  { id: "T-1", title: "Cannot log in" },
  { id: "T-2", title: "" },
  { id: "T-3", title: "Login link expired" },
  { id: "T-4", title: "Password reset email missing" },
  { id: "T-5", title: "Invoice total is wrong" },
];

/** Pretend each ticket takes a moment to export. */
export async function exportRow(ticket: { id: string; title: string }) {
  await new Promise((resolve) => setTimeout(resolve, 150));
  return `${ticket.id},"${ticket.title}"`;
}
```

Each call to `notify()` sends a `notifications/message`:

- A string becomes `data: { message: "..." }`. An object is sent as `data` as it is, for clients that read structured logs.
- `level` is one of MCP's log levels, from least to most severe: `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"` and `"emergency"`. It defaults to `"info"`.
- FrontMCP sets `logger` to the tool's name, so the client knows which tool said it.

The client chooses the lowest level it wants, and FrontMCP drops anything below it. The Call tab asks for `"info"` and up, which is why the `debug` message with the byte count isn't in the list above.

> **Note**
A client may show notifications to the user or write them to a log, and it doesn't have to pass them to the model. The model reads the result. If the model needs to know something, like which tickets were skipped, put it in the result, and use `notify()` for the person watching.

## How a client asks for notifications

In MCP 2026-07-28, notifications belong to the request that caused them, and a client asks for them in that request's `_meta`:

- a `progressToken` asks for progress. The server copies the token into every progress notification, so a client with several calls in flight knows which one each update is about.
- `io.modelcontextprotocol/logLevel` asks for log messages at that level and above.

Open the **Wire** tab in the example above and expand the `tools/call`. The request carries both:

```json
{
  "method": "tools/call",
  "params": {
    "name": "export_tickets",
    "arguments": {},
    "_meta": {
      "progressToken": "progress-1",
      "io.modelcontextprotocol/logLevel": "info"
    }
  }
}
```

(The real request's `_meta` also holds the protocol version, client info and capabilities.) When a request asks for either, FrontMCP answers with `Content-Type: text/event-stream` instead of plain JSON. Each notification is an event, sent as it happens, and the last event is the result:

```text
event: message
data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","logger":"export_tickets","data":{"message":"Exporting 5 tickets to tickets.csv"}}}

event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"progress-1","progress":1,"total":5,"message":"Exported T-1"}}

…

event: message
data: {"jsonrpc":"2.0","id":6,"result":{"content":[…],"structuredContent":{"file":"tickets.csv","rows":5}}}
```

The Wire tab labels this response **SSE** and shows each event before the final response. When the call ends, the stream ends with it.

A client in your own code asks the same way. One made with [`connect()`](https://frontmcp.dev/reference/sdk/connect#hearing-a-tools-progress) passes `{ onProgress }` as `callTool()`'s third argument, which sends a progress token with that call, and gets each update: see [`this.progress()`](https://frontmcp.dev/reference/sdk/progress#asking-for-progress). (Changed in 1.9.3: before, a `connect()` client had no way to ask.)

## When the client doesn't ask

Not every client asks for notifications, and a client that asks for some calls may not ask for others. A tool has to work either way. This version puts what `progress()` returned into the result, so you can see it:

```ts export-tickets.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";
import { exportRow, tickets } from "./store";

@Tool({
  name: "export_tickets",
  description: "Export every support ticket to tickets.csv. Takes a few seconds.",
  inputSchema: {},
})
export class ExportTickets extends ToolContext {
  async execute() {
    let reported = false;
    for (const [i, ticket] of tickets.entries()) {
      await exportRow(ticket);
      reported = await this.progress(i + 1, tickets.length, `Exported ${ticket.id}`);
    }
    // Only here to show what progress() returned.
    return { file: "tickets.csv", rows: tickets.length, progressReported: reported };
  }
}
```

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

test("the export works for a client that doesn't ask for notifications", async ({ mcp }) => {
  const response = await mcp.raw.request({
    jsonrpc: "2.0",
    id: 1,
    method: "tools/call",
    params: { name: "export_tickets", arguments: {} },
  });
  expect(response.result).toMatchObject({
    structuredContent: { file: "tickets.csv", rows: 5, progressReported: false },
  });
});
```

```ts store.ts
export const tickets = [
  { id: "T-1", title: "Cannot log in" },
  { id: "T-2", title: "" },
  { id: "T-3", title: "Login link expired" },
  { id: "T-4", title: "Password reset email missing" },
  { id: "T-5", title: "Invoice total is wrong" },
];

/** Pretend each ticket takes a moment to export. */
export async function exportRow(ticket: { id: string; title: string }) {
  await new Promise((resolve) => setTimeout(resolve, 150));
  return `${ticket.id},"${ticket.title}"`;
}
```

The first call asked for progress, so `progressReported` is `true`. Untick **Stream progress & logs** and call again: the response is plain JSON, with no notifications, and `progressReported` is `false`. The export itself is the same.

`progress()` and `notify()` return `true` when the notification was queued for the client, and `false` when the client didn't ask for it (or, for `notify()`, asked only for higher levels). They never throw for that reason, so you don't need to check before calling them. The test file does the same thing from code: `mcp.raw.request()` sends a request exactly as you write it, without a `progressToken`.

## Testing progress and logs

The test client records the notifications that come back. `mcp.notifications.collectProgress().all` lists the progress updates, and `mcp.notifications.collect().received` lists every notification, logs included. In the Playground, each `mcp.tools.call()` asks for progress and for every log level, `debug` included:

```ts export-tickets.test.ts active
import { test, expect } from "@frontmcp/testing";

test("reports progress once per ticket", async ({ mcp }) => {
  const progress = mcp.notifications.collectProgress();
  const result = await mcp.tools.call("export_tickets", {});

  expect(result).toBeSuccessful();
  expect(progress.all.map((p) => p.progress)).toEqual([1, 2, 3, 4, 5]);
  expect(progress.all.every((p) => p.total === 5)).toBe(true);
});

type LogMessage = { level: string; logger?: string; data: { message?: string } };

test("warns about the ticket with no title", async ({ mcp }) => {
  const notifications = mcp.notifications.collect();
  await mcp.tools.call("export_tickets", {});

  const warnings = notifications.received
    .filter((n) => n.method === "notifications/message")
    .map((n) => n.params as LogMessage)
    .filter((log) => log.level === "warning");
  expect(warnings).toHaveLength(1);
  expect(warnings[0].data.message).toContain("T-2");
});
```

```ts export-tickets.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";
import { exportRow, tickets } from "./store";

@Tool({
  name: "export_tickets",
  description: "Export every support ticket to tickets.csv. Takes a few seconds.",
  inputSchema: {},
})
export class ExportTickets extends ToolContext {
  async execute() {
    await this.notify(`Exporting ${tickets.length} tickets to tickets.csv`);

    const rows: string[] = [];
    for (const [i, ticket] of tickets.entries()) {
      if (!ticket.title) await this.notify(`${ticket.id} has no title. It's exported with an empty one.`, "warning");
      rows.push(await exportRow(ticket));
      await this.progress(i + 1, tickets.length, `Exported ${ticket.id}`);
    }

    await this.notify({ file: "tickets.csv", bytes: rows.join("\n").length }, "debug");
    return { file: "tickets.csv", rows: rows.length };
  }
}
```

```ts store.ts
export const tickets = [
  { id: "T-1", title: "Cannot log in" },
  { id: "T-2", title: "" },
  { id: "T-3", title: "Login link expired" },
  { id: "T-4", title: "Password reset email missing" },
  { id: "T-5", title: "Invoice total is wrong" },
];

/** Pretend each ticket takes a moment to export. */
export async function exportRow(ticket: { id: string; title: string }) {
  await new Promise((resolve) => setTimeout(resolve, 150));
  return `${ticket.id},"${ticket.title}"`;
}
```

> **Pitfall: Under frontmcp test, ask for notifications and wait for them**
The Playground's test client asks for progress and for every log level with each call. The one `frontmcp test` uses records notifications too, but sends a `progressToken` only once `collectProgress()` is active, never sets a log level by itself, and can return from `tools.call()` before the notifications arrive. So in your project, call `collectProgress()` before the call, send `logging/setLevel` for log messages, and wait with `waitForComplete()` or `waitFor()`. [Checking notifications](https://frontmcp.dev/reference/testing#checking-notifications) has a test that passes in both.

## Recap

- `this.progress(progress, total, message)` reports how far along a tool is. `progress` should go up with every call, and `total` and `message` are optional.
- `this.notify(message, level)` sends a log message. A string becomes `data.message`, an object is sent as `data`, and `level` defaults to `"info"`.
- Under 2026-07-28 a client asks per request, with a `progressToken` and `io.modelcontextprotocol/logLevel` in `_meta`. The response is then an event stream: notifications first, the result last.
- A client that doesn't ask gets a plain JSON result. `progress()` and `notify()` return `false`, and the call works the same.
- Notifications are for the client and the person watching. Put what the model needs in the result.

## Try some challenges

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

### Challenge: Report progress while importing
`import_tickets` copies tickets from the old help desk, one at a time, and says nothing until it's done. Report progress after each ticket, with the total number of tickets.

```ts import-tickets.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const imported: { id: string; title: string }[] = [];

@Tool({
  name: "import_tickets",
  description: "Import tickets from the old help desk. Takes about a second per 5 tickets.",
  inputSchema: {
    tickets: z.array(z.object({ id: z.string(), title: z.string() })).min(1).max(20),
  },
})
export class ImportTickets extends ToolContext {
  async execute({ tickets }: { tickets: { id: string; title: string }[] }) {
    for (const ticket of tickets) {
      await new Promise((resolve) => setTimeout(resolve, 100));
      imported.push(ticket);
    }
    return { imported: tickets.length };
  }
}
```

```ts import-tickets.tool.ts solution
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const imported: { id: string; title: string }[] = [];

@Tool({
  name: "import_tickets",
  description: "Import tickets from the old help desk. Takes about a second per 5 tickets.",
  inputSchema: {
    tickets: z.array(z.object({ id: z.string(), title: z.string() })).min(1).max(20),
  },
})
export class ImportTickets extends ToolContext {
  async execute({ tickets }: { tickets: { id: string; title: string }[] }) {
    for (const [i, ticket] of tickets.entries()) {
      await new Promise((resolve) => setTimeout(resolve, 100));
      imported.push(ticket);
      await this.progress(i + 1, tickets.length, `Imported ${ticket.id}`);
    }
    return { imported: tickets.length };
  }
}
```

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

const three = [
  { id: "OLD-1", title: "Printer on fire" },
  { id: "OLD-2", title: "Cannot log in" },
  { id: "OLD-3", title: "Refund request" },
];

test("there's a progress update for each ticket", async ({ mcp }) => {
  const progress = mcp.notifications.collectProgress();
  await mcp.tools.call("import_tickets", { tickets: three });
  expect(progress.all.length).toBeGreaterThanOrEqual(3);
});

test("every update has `total` set to the number of tickets", async ({ mcp }) => {
  const progress = mcp.notifications.collectProgress();
  await mcp.tools.call("import_tickets", { tickets: three });
  expect(progress.all.length).toBeGreaterThan(0);
  expect(progress.all.map((p) => p.total)).toEqual(progress.all.map(() => 3));
});

test("progress goes up and ends at the total", async ({ mcp }) => {
  const progress = mcp.notifications.collectProgress();
  await mcp.tools.call("import_tickets", { tickets: three });
  const values = progress.all.map((p) => p.progress);
  expect(values.length).toBeGreaterThan(0);
  expect(values.every((v, i) => i === 0 || v > values[i - 1])).toBe(true);
  expect(values.at(-1)).toBe(3);
});

test("the import still returns its result", async ({ mcp }) => {
  const result = await mcp.tools.call("import_tickets", { tickets: three });
  expect(result.json()).toEqual({ imported: 3 });
});
```

**Hint:**
`tickets.entries()` gives you the index as well as the ticket. The first ticket done is progress `1`, not `0`.

**Solution:**
After each ticket is imported, `this.progress(i + 1, tickets.length, ...)` reports how many are done out of how many. The last update has `progress` equal to `total`, which is how a client knows the bar is full.

### Challenge: Warn about skipped tickets
Tickets with an empty title can't be imported, and this tool skips them without a word. Send a `"warning"` log message for each skipped ticket, naming its id. Don't warn about tickets that were imported.

```ts import-tickets.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const imported: { id: string; title: string }[] = [];

@Tool({
  name: "import_tickets",
  description: "Import tickets from the old help desk. Tickets without a title are skipped.",
  inputSchema: {
    tickets: z.array(z.object({ id: z.string(), title: z.string() })).min(1).max(20),
  },
})
export class ImportTickets extends ToolContext {
  async execute({ tickets }: { tickets: { id: string; title: string }[] }) {
    let count = 0;
    for (const ticket of tickets) {
      if (!ticket.title.trim()) continue;
      imported.push(ticket);
      count++;
    }
    return { imported: count };
  }
}
```

```ts import-tickets.tool.ts solution
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const imported: { id: string; title: string }[] = [];

@Tool({
  name: "import_tickets",
  description: "Import tickets from the old help desk. Tickets without a title are skipped.",
  inputSchema: {
    tickets: z.array(z.object({ id: z.string(), title: z.string() })).min(1).max(20),
  },
})
export class ImportTickets extends ToolContext {
  async execute({ tickets }: { tickets: { id: string; title: string }[] }) {
    let count = 0;
    for (const ticket of tickets) {
      if (!ticket.title.trim()) {
        await this.notify(`Skipped ${ticket.id}: it has no title.`, "warning");
        continue;
      }
      imported.push(ticket);
      count++;
    }
    return { imported: count };
  }
}
```

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

const warningsIn = (notifications: { received: any[] }) =>
  notifications.received.filter((n) => n.method === "notifications/message" && n.params.level === "warning");

test("a skipped ticket gets a warning that names it", async ({ mcp }) => {
  const notifications = mcp.notifications.collect();
  await mcp.tools.call("import_tickets", {
    tickets: [{ id: "OLD-1", title: "Printer on fire" }, { id: "OLD-2", title: "" }],
  });
  const warnings = warningsIn(notifications);
  expect(warnings).toHaveLength(1);
  expect(JSON.stringify(warnings[0].params.data)).toContain("OLD-2");
});

test("each skipped ticket gets its own warning", async ({ mcp }) => {
  const notifications = mcp.notifications.collect();
  await mcp.tools.call("import_tickets", {
    tickets: [{ id: "OLD-3", title: " " }, { id: "OLD-4", title: "" }, { id: "OLD-5", title: "Refund request" }],
  });
  const text = warningsIn(notifications).map((w) => JSON.stringify(w.params.data));
  expect(text).toHaveLength(2);
  expect(text.some((t) => t.includes("OLD-3"))).toBe(true);
  expect(text.some((t) => t.includes("OLD-4"))).toBe(true);
});

test("no warnings when nothing is skipped", async ({ mcp }) => {
  const notifications = mcp.notifications.collect();
  await mcp.tools.call("import_tickets", { tickets: [{ id: "OLD-6", title: "Cannot log in" }] });
  expect(warningsIn(notifications)).toHaveLength(0);
});

test("the import still counts what it imported", async ({ mcp }) => {
  const result = await mcp.tools.call("import_tickets", {
    tickets: [{ id: "OLD-7", title: "Printer on fire" }, { id: "OLD-8", title: "" }],
  });
  expect(result.json()).toEqual({ imported: 1 });
});
```

**Hint:**
`this.notify(message, "warning")`. Put the ticket's id in the message.

**Solution:**
Each skipped ticket sends one `notifications/message` at level `"warning"`, which clients can show differently from routine `"info"` messages. The result is unchanged: the warnings are for the person watching the import.

### Challenge: Tell the model what was skipped
This version warns about skipped tickets, but the model only gets `{ imported: 1 }` and has no idea anything went wrong. Add a `skipped` list of ticket ids to the result, so the model can tell the user. It must work for clients that don't ask for notifications, too.

```ts import-tickets.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const imported: { id: string; title: string }[] = [];

@Tool({
  name: "import_tickets",
  description: "Import tickets from the old help desk. Tickets without a title are skipped.",
  inputSchema: {
    tickets: z.array(z.object({ id: z.string(), title: z.string() })).min(1).max(20),
  },
})
export class ImportTickets extends ToolContext {
  async execute({ tickets }: { tickets: { id: string; title: string }[] }) {
    let count = 0;
    for (const ticket of tickets) {
      if (!ticket.title.trim()) {
        await this.notify(`Skipped ${ticket.id}: it has no title.`, "warning");
        continue;
      }
      imported.push(ticket);
      count++;
    }
    return { imported: count };
  }
}
```

```ts import-tickets.tool.ts solution
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const imported: { id: string; title: string }[] = [];

@Tool({
  name: "import_tickets",
  description: "Import tickets from the old help desk. Tickets without a title are skipped and listed in `skipped`.",
  inputSchema: {
    tickets: z.array(z.object({ id: z.string(), title: z.string() })).min(1).max(20),
  },
})
export class ImportTickets extends ToolContext {
  async execute({ tickets }: { tickets: { id: string; title: string }[] }) {
    const skipped: string[] = [];
    for (const ticket of tickets) {
      if (!ticket.title.trim()) {
        skipped.push(ticket.id);
        await this.notify(`Skipped ${ticket.id}: it has no title.`, "warning");
        continue;
      }
      imported.push(ticket);
    }
    return { imported: tickets.length - skipped.length, skipped };
  }
}
```

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

test("the result lists the skipped ids in `skipped`", async ({ mcp }) => {
  const result = await mcp.tools.call("import_tickets", {
    tickets: [{ id: "OLD-1", title: "Printer on fire" }, { id: "OLD-2", title: "" }, { id: "OLD-3", title: " " }],
  });
  expect(result.json()).toEqual({ imported: 1, skipped: ["OLD-2", "OLD-3"] });
});

test("`skipped` is empty when nothing is skipped", async ({ mcp }) => {
  const result = await mcp.tools.call("import_tickets", { tickets: [{ id: "OLD-4", title: "Cannot log in" }] });
  expect(result.json()).toEqual({ imported: 1, skipped: [] });
});

test("a client that doesn't ask for notifications still gets `skipped`", async ({ mcp }) => {
  const response = await mcp.raw.request({
    jsonrpc: "2.0",
    id: 1,
    method: "tools/call",
    params: { name: "import_tickets", arguments: { tickets: [{ id: "OLD-5", title: "" }] } },
  });
  expect(response.result).toMatchObject({ structuredContent: { imported: 0, skipped: ["OLD-5"] } });
});

test("skipped tickets still get a warning", async ({ mcp }) => {
  const notifications = mcp.notifications.collect();
  await mcp.tools.call("import_tickets", { tickets: [{ id: "OLD-6", title: "" }] });
  expect(notifications.received.some((n) => n.params?.level === "warning")).toBe(true);
});
```

**Hint:**
Collect the ids in an array as you skip them, and return it next to `imported`.

**Solution:**
The warning tells the person watching; `skipped` tells the model, which reads the result whether or not the client asked for notifications. Keep both: they're for different readers.
