this.fetch
this.fetch() is the web platform's fetch(), with two additions for calling other services from inside a request: it sends the request's trace and id along, so the call can be followed across services, and it gives up after 30 seconds unless you choose another timeout. It doesn't send the caller's token or headers anywhere unless you list the service. Everything else, from the options to the Response you get back, is plain fetch().
const response = await this.fetch(input, init?)
Reference
this.fetch(input, init?)
Call this.fetch() inside the execute() of a tool or a resource.
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "get_invoice",
description: "Get a customer's invoice from the billing service",
inputSchema: { id: z.string().describe("Invoice id, like INV-7") },
annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
async execute({ id }: { id: string }) {
const response = await this.fetch(`https://billing.example/invoices/${id}`);
if (!response.ok) this.fail(new PublicMcpError(`There's no invoice ${id}.`));
return await response.json();
}
}Parameters
| Parameter | Type | Description |
|---|---|---|
input | string | URL | Request | What to fetch. Use an absolute URL. A Request works too, with its own headers, unless init has headers of its own. |
init | RequestInit | Optional. The same options as fetch(): method, headers, body, signal and the rest. |
Three options behave differently from plain fetch():
| Option | In this.fetch() |
|---|---|
headers | Sent as you give them. FrontMCP adds its own only where you haven't set a header of the same name. |
signal | Replaces FrontMCP's 30-second timeout. Without one, FrontMCP adds its own signal that aborts after 30 seconds, requestTimeout if you set one. |
credentials | A string ("include", "omit", "same-origin") is passed on. The types also accept { provider: "…" }, which adds the headers the server's auth-provider registry gives for that provider. FrontMCP 1.9.3 doesn't register such a registry itself, so out of the box the option is dropped, nothing is added, and a warning is logged. |
Returns
A promise of a standard Response. Like fetch(), it resolves for every HTTP status, 404 and 500 included, so check response.ok before you use the body.
What it adds
| Header | Value | Sent |
|---|---|---|
traceparent | The request's W3C trace context, this.context.traceContext.raw. | Unless autoInjectTracingHeaders is false. A traceparent header the client sent is passed on as it arrived; otherwise each request starts a new trace. With tracing on, the same trace with this request's own GET span as the parent, so the service you call records its work under it. (Changed in 1.9.3: before, tracing passed on the request's own.) |
x-request-id | this.context.requestId, new for every request. | Unless autoInjectTracingHeaders is false. |
Authorization | Bearer, then the token the caller authenticated with, this.context.authInfo.token. | Only to origins in forwardCallerTokenTo, which is empty by default. |
x-frontmcp-* | Every header of the client's request whose name starts with x-frontmcp-. | Only to origins in forwardCustomHeadersTo, which is empty by default. |
A header you set yourself is never replaced. While a request carries the caller's token or headers, or asks for credentials: { provider }, this.fetch() doesn't follow redirects (redirect: "manual"), so a redirect can't take them to an origin you didn't list; the 3xx response comes back to you instead. An anonymous caller has no token, so a listed origin gets no Authorization header from it.
Server options
@FrontMcp({ fetch }) sets how this.fetch() behaves for the whole server:
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
fetch: { forwardCallerTokenTo: ["https://tickets-api.example.com"], requestTimeout: 10_000 },
})
export default class Server {}| Option | Default | What it does |
|---|---|---|
forwardCallerTokenTo | [] | Origins that get Authorization: Bearer and the caller's token. Write each as a URL; only its origin counts (scheme, host and port), so https://tickets-api.example.com/v2 covers every path on that host, and https://tickets-api.example.com:8443 is another origin. |
forwardCustomHeadersTo | [] | Origins that get the request's x-frontmcp-* headers. |
requestTimeout | 30000 | Milliseconds before a request without a signal is aborted. |
autoInjectTracingHeaders | true | Whether to send traceparent and x-request-id. |
An entry that isn't a URL, like "tickets-api.example.com", stops the server from starting with Invalid URL.
Errors
this.fetch() rejects when fetch() would:
| Error | When |
|---|---|
TypeError: "fetch failed" in Node, "Failed to fetch" in browsers | The service couldn't be reached: the name didn't resolve, the connection was refused, TLS failed, or, in a browser, CORS blocked the response. |
TypeError: "Failed to parse URL from …" | In Node, the URL is relative or malformed. |
DOMException named TimeoutError: "The operation was aborted due to timeout" | FrontMCP's 30 seconds ran out. Your own AbortSignal.timeout() gives the same error name, with the runtime's own message. |
DOMException named AbortError | A signal you passed was aborted. |
If you don't catch the error, the request fails. A tool call gets an isError result with _meta.code TOOL_EXECUTION_ERROR, reading Tool "get_invoice" execution failed: fetch failed in development and "Internal FrontMCP error" in production. A resource read fails with error -32603: Resource "invoices://INV-7" read failed: fetch failed, and "Internal FrontMCP error" in production.
Caveats
this.fetch()is available inToolContext,ResourceContextandPromptContext. Prompts have it since 1.9.2; before, a prompt calledthis.get(FRONTMCP_CONTEXT).fetch(), which still does the same thing.- It's still
fetch(): no retries, no base URL, no JSON parsing, no caching. - Change the 30 seconds for the whole server with
requestTimeout, or for one request by passing asignal. A signal that never aborts, like a plainAbortController's, means no timeout at all. - A
Requestkeeps its headers and gets the default timeout. Passinit.headerswith it, though, and those replace every header theRequesthad, as withfetch(). - The caller's token stays on your server unless you list a service in
forwardCallerTokenTo. FrontMCP 1.8.0 sent it to every host; on 1.8.0, setAuthorizationyourself on every call. An anonymous caller has no token, and a listed origin gets noAuthorizationheader for it (before 1.9.2 it gotAuthorization: Bearerwith nothing after it). create()keeps thefetchoption, ascreateDirect()does, so a call with anauthContexttoken sends it to the origins inforwardCallerTokenTo: see forwarding the caller's token. (Changed in 1.9.3: before,create()droppedfetch.)- The request is made by the server's runtime. In Node, CORS doesn't apply. In the Playground, which runs in your browser, a real service must allow CORS.
Usage
Calling an HTTP API
Check response.ok and fail with a message the model can act on, then return the body.
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "get_invoice",
description: "Get a customer's invoice from the billing service",
inputSchema: { id: z.string().describe("Invoice id, like INV-7") },
annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
async execute({ id }: { id: string }) {
const response = await this.fetch(`https://billing.example/invoices/${id}`);
if (response.status === 404) this.fail(new PublicMcpError(`There's no invoice ${id}. Invoice ids look like INV-7.`));
if (!response.ok) this.fail(new PublicMcpError(`The billing service answered HTTP ${response.status}. Try again later.`));
return await response.json();
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The billing-api.ts tab stands in for the billing service, so the example runs without a network. It replaces the global fetch() for https://billing.example only, and this.fetch() ends in the global fetch(), so the tool's request lands there.
Seeing what this.fetch() adds
This tool calls the same service twice, once with the global fetch() and once with this.fetch(), and the service answers with the headers it got. Only this.fetch() sends the request's trace and id, and a signal for the timeout. There's no Authorization: the server doesn't list any service in its fetch options.
import { Tool, ToolContext } from "@frontmcp/sdk";
@Tool({ name: "compare_fetch", description: "Show what this.fetch() adds to a request", inputSchema: {} })
export class CompareFetch extends ToolContext {
async execute() {
const plain = await (await fetch("https://echo.example/")).json();
const withContext = await (await this.fetch("https://echo.example/")).json();
return {
requestId: this.context.requestId,
traceparent: this.context.traceContext.raw,
plain,
withContext,
};
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Sending JSON with your own credentials
Pass method, headers and body as you would to fetch(). A service's API key is your server's secret, not the caller's, so keep it in a provider and set Authorization yourself. FrontMCP keeps your header as it is.
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { BillingConfig } from "./billing-config";
@Tool({
name: "refund_invoice",
description: "Refund part or all of a customer's invoice",
inputSchema: { invoice: z.string().describe("Invoice id, like INV-7"), amount: z.number().positive() },
annotations: { destructiveHint: true, openWorldHint: true },
})
export class RefundInvoice extends ToolContext {
async execute({ invoice, amount }: { invoice: string; amount: number }) {
const billing = this.get(BillingConfig);
const response = await this.fetch(`${billing.baseUrl}/refunds`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${billing.apiKey}` },
body: JSON.stringify({ invoice, amount }),
});
if (!response.ok) this.fail(new PublicMcpError(`The billing service refused the refund (HTTP ${response.status}).`));
return await response.json();
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Change apiKey in billing-config.ts and call the tool again: the service answers 401, and the tool fails with a message.
Forwarding the caller's token to your own service
The caller's token was issued for your MCP server. Send it on only to a service of your own that accepts the same tokens, and list that service's origin in forwardCallerTokenTo. Every other host still gets nothing.
import { App, FrontMcp } from "@frontmcp/sdk";
import { CheckUpstreams } from "./check-upstreams.tool";
@App({ id: "help-desk", name: "Help Desk", tools: [CheckUpstreams] })
class HelpDeskApp {}
export const config = {
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
// Your own tickets API trusts the same identity provider as this server.
fetch: { forwardCallerTokenTo: ["https://tickets-api.example"] },
};
@FrontMcp(config)
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Only list a service that is meant to accept these tokens, such as one whose audience your identity provider includes. A third-party API never is: give it its own credentials, as above.
Setting a timeout
Without a signal, this.fetch() waits up to 30 seconds, which is a long time for a model to wait. Pass AbortSignal.timeout() to give up sooner, and turn the TimeoutError into a message the model can act on. The reports service here takes 3 seconds to answer.
import { PublicMcpError, Tool, ToolContext } from "@frontmcp/sdk";
@Tool({ name: "weekly_report", description: "How many tickets were opened and closed this week", inputSchema: {} })
export class WeeklyReport extends ToolContext {
async execute() {
let response: Response;
try {
response = await this.fetch("https://reports.example/weekly", { signal: AbortSignal.timeout(500) });
} catch (e) {
if (e instanceof DOMException && e.name === "TimeoutError") {
this.fail(new PublicMcpError("The reports service didn't answer within half a second. Try again later."));
}
throw e;
}
return await response.json();
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Change the timeout to 5000 and the report comes back after 3 seconds.
When the service can't be reached
A service that's down or unreachable makes this.fetch() reject with a TypeError. Uncaught, the model gets "Internal FrontMCP error" in production, which tells it nothing, so catch it and say what happened. billing.invalid never resolves: the .invalid domain is reserved for names that don't exist.
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "get_invoice",
description: "Get a customer's invoice from the billing service",
inputSchema: { id: z.string().describe("Invoice id, like INV-7") },
})
export class GetInvoice extends ToolContext {
async execute({ id }: { id: string }) {
let response: Response;
try {
response = await this.fetch(`https://billing.invalid/invoices/${id}`);
} catch (e) {
if (e instanceof TypeError) {
this.fail(new PublicMcpError("The billing service can't be reached right now. Try again in a few minutes."));
}
throw e;
}
if (!response.ok) this.fail(new PublicMcpError(`There's no invoice ${id}.`));
return await response.json();
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Fetching from a resource or a prompt
Resources and prompts have this.fetch() too, with the same headers and timeout. A PublicMcpError reaches the client with its message from both, whether you throw it or pass it to this.fail() (see this.fail).
Other entries
Example 1 of 2
Resource
import { PublicMcpError, ResourceContext, ResourceTemplate } from "@frontmcp/sdk";
@ResourceTemplate({ name: "invoice", uriTemplate: "invoices://{id}", mimeType: "application/json" })
export class Invoice extends ResourceContext<{ id: string }> {
async execute(uri: string, { id }: { id: string }) {
const response = await this.fetch(`https://billing.example/invoices/${id}`);
if (!response.ok) throw new PublicMcpError(`There's no invoice ${id}.`);
return { contents: [{ uri, mimeType: "application/json", text: await response.text() }] };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Troubleshooting
Tool "get_invoice" execution failed: fetch failed
this.fetch() rejected and nothing caught it. "fetch failed" is Node's message for a service it couldn't reach; browsers, like the Playground, say "Failed to fetch". In production the model gets "Internal FrontMCP error" instead. Check the URL and that the service is up, then catch the error and fail with a message of your own.
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "get_invoice", description: "Get a customer's invoice", inputSchema: { id: z.string() } })
export class GetInvoice extends ToolContext {
async execute({ id }: { id: string }) {
const response = await this.fetch(`https://billing.invalid/invoices/${id}`); // 🚩 nothing catches a failure
return await response.json();
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The operation was aborted due to timeout
The service didn't answer within FrontMCP's default of 30 seconds, so the call was aborted: a tool call fails with Tool "…" execution failed: The operation was aborted due to timeout. Pass a signal with the time you want, and turn the timeout into a message.
Failed to parse URL from /invoices/INV-7
The URL is relative. In Node there's no page to resolve it against, so fetch() rejects. Build absolute URLs from a base URL in your configuration, as refund_invoice does with a provider. (In a browser, a relative URL resolves against the page, so the same code fetches from the wrong server instead of failing.)
The service says 401, though the Request has an Authorization header
this.fetch() was given a Request object and init.headers next to it. As with fetch(), headers in init replace every header the Request had, the API key included:
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "refund_invoice",
description: "Refund part or all of a customer's invoice",
inputSchema: { invoice: z.string(), amount: z.number().positive() },
})
export class RefundInvoice extends ToolContext {
async execute({ invoice, amount }: { invoice: string; amount: number }) {
const request = new Request("https://billing.example/refunds", {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer sk_test_desk" },
body: JSON.stringify({ invoice, amount }),
});
const response = await this.fetch(request, { headers: { "idempotency-key": `refund-${invoice}-${amount}` } }); // 🚩 replaces the headers above
if (!response.ok) this.fail(new PublicMcpError(`The billing service refused the refund (HTTP ${response.status}).`));
return await response.json();
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Put every header in one place:
// 🚩 The Request's headers are replaced
await this.fetch(new Request(url, { method: "POST", headers, body }), { headers: { "idempotency-key": key } });
// ✅ Your headers are sent, with FrontMCP's added
await this.fetch(url, { method: "POST", headers: { ...headers, "idempotency-key": key }, body });
The service gets no credentials from credentials.provider
The types accept credentials: { provider: "billing" }, and this.fetch() asks the server's auth-provider registry for that provider's headers. FrontMCP 1.9.2 doesn't register one itself, and @frontmcp/sdk doesn't export the token to register one under, so the option is removed, nothing is added in its place, and the server logs a warning (look in the Logs tab):
fetch(): credentials.provider was given, but no auth providers are configured; sent without them
The request still doesn't follow redirects, as with any credential:
import { Tool, ToolContext } from "@frontmcp/sdk";
@Tool({ name: "ping_billing", description: "Check what the billing service receives", inputSchema: {} })
export class PingBilling extends ToolContext {
async execute() {
const response = await this.fetch("https://echo.example/", { credentials: { provider: "billing" } });
return await response.json();
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Set the header yourself from a provider, as in Sending JSON with your own credentials.
The service gets no Authorization header, though the caller signed in
this.fetch() doesn't send the caller's token to a service unless its origin is in forwardCallerTokenTo. Send the service's own credentials, as in Sending JSON with your own credentials, or, for a service of your own that accepts the same tokens, list it.