Calling Other Services

Advanced

Sooner or later a tool needs data that lives somewhere else: a billing API, a CRM, another team's service. this.fetch() is the web's fetch with the current request attached. Making the call is the easy part. This lesson is about everything around it: a service that's down or slow, a response full of data the model shouldn't see, and credentials that end up where they shouldn't.

You will learn

  • What this.fetch() adds to a request
  • Which credentials to send, and why not the caller's
  • How to turn failures and timeouts into errors the model can act on
  • How to keep upstream secrets and raw responses away from the model

Calling a service from a tool

Support agents often need a customer's invoice, and invoices live in the billing service. Here's a first version of a tool that fetches one. (If the service has an OpenAPI description, the OpenAPI adapter can generate a tool for each operation instead; this lesson writes the tool by hand, which is what you do when you want to shape what the model sees.)

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

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const res = await fetch(`https://billing.example/invoices/${id}`);
    return await res.json();
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The tool works, and it has four problems, which the rest of this lesson fixes one at a time:

  1. The model gets everything the service sent, including a card token and internal notes it should never see.
  2. Failures look like answers. A 404 comes back as a successful result that says {"error":"not_found"}.
  3. Nothing limits how long it waits. If billing hangs, so does the call.
  4. Billing can't tell who is calling. Nothing links its logs to yours.

Using this.fetch()

Start with the last problem, and the first. Switch fetch to this.fetch(), and build the result from the fields the model needs:

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

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const res = await this.fetch(`https://billing.example/invoices/${id}`);
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

this.fetch() takes the same arguments as fetch and returns the same Response. What it adds is the request you're in. Each of these is added only if you haven't set it yourself:

AddedValue
x-request-id headerthis.context.requestId, so billing's logs can be matched with yours.
traceparent headerThe request's trace context, so a tracing system can join your server's work and billing's into one trace.
A timeout30 seconds, when you don't pass a signal of your own.

What it doesn't add matters as much: the caller's token and their x-frontmcp-* headers stay on your server, as the next section shows.

The other change is the result: the tool picks four fields and formats the total, and everything else the service sent stays in the tool. The service's JSON is a lot like a database row. It carries whatever billing needed, it can grow new fields without telling you, and Shaping Tool Results explains why the model should get a result you built on purpose.

Sending your own credentials

Your server authenticates its clients, usually with an OAuth access token. That token was issued for your MCP server, not for billing, and this.fetch() doesn't send it on. The Playground has no authentication, so this test uses create() to call the tool as a signed-in user whose token is mcp-token-for-nour, and looks at what billing received:

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

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const res = await this.fetch(`https://billing.example/invoices/${id}`);
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Forwarding it would hand the user's credential to another service, and if that service accepted it, the model could reach it with the user's rights, which nobody agreed to. The MCP specification calls this token passthrough and forbids it. (FrontMCP 1.8.0 did send the caller's token to every host. If you're still on it, set Authorization yourself on every call.)

So billing gets no credentials at all. Give each service its own. this.fetch() never replaces an Authorization header you set:

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

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const res = await this.fetch(`https://billing.example/invoices/${id}`, {
      headers: { Authorization: `Bearer ${BILLING_API_KEY}` },
    });
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Keep the key where the model can't see it: out of results, out of error messages, and out of URLs, which tend to end up in logs. The same goes for anything the service sends back about itself.

When the service fails

Now the second problem. A response isn't an answer until you've checked its status. The obvious fix is to pass billing's error on:

Open
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { BILLING_API_KEY } from "./secrets";

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const res = await this.fetch(`https://billing.example/invoices/${id}`, {
      headers: { Authorization: `Bearer ${BILLING_API_KEY}` },
    });
    if (!res.ok) {
      this.fail(new PublicMcpError(`Billing error ${res.status}: ${await res.text()}`));
    }
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The model now learns the name and port of billing's database server, and nothing it can do about it. And because the message is in a PublicMcpError, it gets it word for word in production too. The model needs to know one of two things: that it asked for something that doesn't exist, so it should fix its request, or that billing is having trouble, so it should wait or carry on without the invoice. Say exactly that, and log the details for yourself:

Open
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { BILLING_API_KEY } from "./secrets";

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

const unavailable = () =>
  new PublicMcpError(
    "The billing service isn't answering right now. Try again in a few minutes, or carry on without the invoice.",
    "BILLING_UNAVAILABLE",
  );

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    let res: Response;
    try {
      res = await this.fetch(`https://billing.example/invoices/${id}`, {
        headers: { Authorization: `Bearer ${BILLING_API_KEY}` },
      });
    } catch (err) {
      this.contextLogger.error(`billing request failed: ${String(err)}`);
      this.fail(unavailable());
    }
    if (res.status === 404) {
      this.fail(new PublicMcpError(`There's no invoice ${id}. Check the id with the user.`, "INVOICE_NOT_FOUND"));
    }
    if (!res.ok) {
      this.contextLogger.error(`billing answered ${res.status}: ${await res.text()}`);
      this.fail(unavailable());
    }
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Each failure gets a code, so programs and tests can tell them apart, and a message that says what to do next. The catch handles the other way a call fails: this.fetch() throws, rather than returning a response, when the service can't be reached at all. The details go to the log, tagged with the request id, as in Reading the Request.

When the service is slow

The last problem left is waiting. INV-8 never answers. With no signal, this.fetch() waits 30 seconds before it gives up, and the user waits with it. Decide how long this service may take, pass that as signal, and tell the model what happened when the time runs out:

Open
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { BILLING_API_KEY } from "./secrets";

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

const unavailable = () =>
  new PublicMcpError(
    "The billing service isn't answering right now. Try again in a few minutes, or carry on without the invoice.",
    "BILLING_UNAVAILABLE",
  );

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    let res: Response;
    try {
      res = await this.fetch(`https://billing.example/invoices/${id}`, {
        headers: { Authorization: `Bearer ${BILLING_API_KEY}` },
        signal: AbortSignal.timeout(2_000),
      });
    } catch (err) {
      if ((err as Error).name === "TimeoutError") {
        this.fail(new PublicMcpError("The billing service didn't answer within 2 seconds. Try again later, or carry on without the invoice.", "BILLING_TIMEOUT"));
      }
      this.contextLogger.error(`billing request failed: ${String(err)}`);
      this.fail(unavailable());
    }
    if (res.status === 404) {
      this.fail(new PublicMcpError(`There's no invoice ${id}. Check the id with the user.`, "INVOICE_NOT_FOUND"));
    }
    if (!res.ok) {
      this.contextLogger.error(`billing answered ${res.status}: ${await res.text()}`);
      this.fail(unavailable());
    }
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

AbortSignal.timeout() aborts the request, and this.fetch() rejects with an error named TimeoutError. Passing your own signal replaces the 30-second default, so the limit is yours. Keep it well under what a client will wait for a whole tool call, or the client gives up first and the model never sees your message.

Deep diveA time limit for the whole callShow detailsHide details

@Tool also takes timeout: { executeMs }, a limit on the whole of execute(), however many requests it makes:

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

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  timeout: { executeMs: 1_000 },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const res = await this.fetch(`https://billing.example/invoices/${id}`);
    return { status: res.status };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

After a second the call fails with the code EXECUTION_TIMEOUT and Execution of "get_invoice" timed out after 1000ms, in production too. It makes a good backstop, but it doesn't say which service was slow or what to do instead. A timeout on the request, with a message you wrote, tells the model what to do.

Recap

  • this.fetch() is fetch plus the request: it sends x-request-id and traceparent, and gives up after 30 seconds unless you pass a signal.
  • this.fetch() keeps the caller's token and x-frontmcp-* headers on your server. Send each service its own credentials in Authorization.
  • Check res.ok. Map "doesn't exist" and "not available right now" to different codes and messages that tell the model what to do.
  • Keep upstream error bodies, host names and keys out of results and error messages. Log them with this.contextLogger instead.
  • Set a timeout per request with signal: AbortSignal.timeout(ms), and catch the TimeoutError.
  • Keep this.fail() out of try blocks whose catch would swallow it.

Try some challenges

Challenge 1 of 3

Tell the model how long to wait

When billing is busy it answers 429 Too Many Requests with a Retry-After header, the number of seconds to wait. get_invoice treats that like an outage. Fail with the code BILLING_BUSY instead, and a message that says how many seconds to wait. Try it with INV-6.

Open
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { BILLING_API_KEY } from "./secrets";

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

const unavailable = () =>
  new PublicMcpError(
    "The billing service isn't answering right now. Try again in a few minutes, or carry on without the invoice.",
    "BILLING_UNAVAILABLE",
  );

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    let res: Response;
    try {
      res = await this.fetch(`https://billing.example/invoices/${id}`, {
        headers: { Authorization: `Bearer ${BILLING_API_KEY}` },
        signal: AbortSignal.timeout(2_000),
      });
    } catch (err) {
      this.contextLogger.error(`billing request failed: ${String(err)}`);
      this.fail(unavailable());
    }
    if (res.status === 404) {
      this.fail(new PublicMcpError(`There's no invoice ${id}. Check the id with the user.`, "INVOICE_NOT_FOUND"));
    }
    if (!res.ok) {
      this.contextLogger.error(`billing answered ${res.status}: ${await res.text()}`);
      this.fail(unavailable());
    }
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.