Reading the Request
Most tools need nothing but their input. Now and then a tool needs to know about the call itself: which request this is, so its logs and errors can be found later; who is calling, so it can decide what they may do; which client is on the other end. FrontMCP keeps all of that on this, next to the helpers you already use. This lesson covers what's there in FrontMCP 1.8, what it's for, and which parts you can't trust.
You will learn
- What
this.contextandthis.authtell a tool about the call and the caller - How to tag logs and error messages with the request id
- Where the caller's identity should come from
- Why arguments, client info and headers are claims, not facts
What a tool knows about the call
execute() receives the arguments. Everything else about the call is on this. This tool returns the parts you'll use most. You wouldn't ship it, but call it a few times and watch what changes:
import { Tool, ToolContext } from "@frontmcp/sdk";
@Tool({
name: "describe_call",
description: "Describe the request this call arrived in. For debugging.",
inputSchema: {},
})
export class DescribeCall extends ToolContext {
async execute() {
return {
requestId: this.context.requestId,
traceId: this.context.traceContext.traceId,
caller: this.auth.user.sub,
client: this.clientInfo ?? null,
platform: this.platform,
};
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
requestId and traceId are new on every call, and here so is the caller: the Playground has no authentication, so each request comes from a new anonymous caller, anon: and a random id. client is the Playground's own description of itself, which it sends with every request. Here is what this offers:
On this | What it is |
|---|---|
this.context.requestId | A UUID that FrontMCP gives each request. It isn't the JSON-RPC id, and the client never sees it unless you put it in a result. |
this.context.traceContext | The request's W3C trace context: traceId, parentId, and raw, the value of a traceparent header. A request without one starts a new trace. |
this.contextLogger | A logger whose lines carry the request id and the trace id. |
this.auth | The caller, as your server's authentication established it: user.sub says who, isAnonymous whether they signed in, scopes and roles what they were granted, and claims what their token said, with checks like hasRole(). See Authorizing Calls. |
this.context.authInfo | The raw result of authentication that this.auth is built from, including token, the caller's token itself. |
this.clientInfo and this.platform | The client's name and version, and the AI platform FrontMCP guesses from the name, like "claude" or "cursor". |
this.context.metadata | Parts of the HTTP request: userAgent, clientIp, and customHeaders, which holds every x-frontmcp-* header. |
Not all of them are filled in for every call. The deep dive below shows what FrontMCP 1.9.3 fills in for each way of running a server.
Deep diveWhat each way of running a server fills inShow detailsHide details
What's on this depends on how the request reached FrontMCP:
Node HTTP server (frontmcp dev, a Node build) | createFetchHandler() (Workers, this Playground) | In-process (connect(), create()) | |
|---|---|---|---|
traceContext | Read from the request's traceparent header, or new | Read from the request's traceparent header, or new | New, unless a create() call's metadata continues a trace with x-frontmcp-trace-id |
metadata | User agent, client IP, x-frontmcp-* headers | User agent, x-frontmcp-* headers, and the client IP when the runtime gives it (Bun, Deno, Cloudflare) or FRONTMCP_TRUST_PROXY=1 is set | Empty with connect(); with create(), what each call's metadata option passes |
clientInfo, platform | From initialize, or from each 2026-07-28 request's _meta | From each request's _meta | From connect()'s clientInfo option; not set with create() |
The caller, in this.auth | From your auth configuration | From your auth configuration | Whatever the calling code passes: the user and their scopes |
Clients before MCP 2026-07-28 open a session with an initialize request that includes their clientInfo, and FrontMCP keeps it for the rest of the session. MCP 2026-07-28 has no sessions: a client sends its info in every request's _meta, as io.modelcontextprotocol/clientInfo, and FrontMCP reads it from there. The Playground calls itself frontmcp.dev playground, which isn't a platform FrontMCP recognizes, so this.platform is "generic-mcp". The tests below send a request to a fetch handler with headers of their own, connect with FrontMCP's in-process client, connect(), which sends initialize, and call the tool with create(), which has no client at all (see Running FrontMCP Anywhere):
import { App, Tool, ToolContext } from "@frontmcp/sdk";
@Tool({ name: "describe_request", description: "Which client sent this request, and its trace and headers. For debugging.", inputSchema: {} })
export class DescribeRequest extends ToolContext {
async execute() {
return {
client: this.clientInfo ?? null,
platform: this.platform,
traceparent: this.context.traceContext.raw,
headers: this.context.metadata.customHeaders,
};
}
}
@App({ id: "help-desk", name: "Help Desk", tools: [DescribeRequest] })
export class HelpDesk {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Either way it's the client's own description of itself, fine for choosing a format and useless as proof.
Tagging logs and errors with the request id
When a tool fails for a reason that isn't the model's fault, like a database that's having a bad day, the model can only tell the user something went wrong. This tool does exactly that:
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { reopen } from "./store";
@Tool({
name: "reopen_ticket",
description: "Reopen a closed support ticket.",
inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class ReopenTicket extends ToolContext {
async execute({ id }: { id: string }) {
try {
return await reopen(id);
} catch {
this.fail(new PublicMcpError("Something went wrong. Please try again later."));
}
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
It's safe: the database error stays hidden. But when the user writes to support, "the assistant said something went wrong", nobody can find out which call failed or why. FrontMCP does log the failure, as a warning with an error id, but that line holds only the message the tool wrote: the database error is gone. And the message the model got has nothing for the user to quote.
Log the real error with this.contextLogger, and give the user a reference to quote:
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { reopen } from "./store";
@Tool({
name: "reopen_ticket",
description: "Reopen a closed support ticket.",
inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class ReopenTicket extends ToolContext {
async execute({ id }: { id: string }) {
try {
return await reopen(id);
} catch (err) {
this.contextLogger.error(`reopen ${id} failed: ${String(err)}`);
this.fail(
new PublicMcpError(
`The ticket system couldn't reopen ${id} just now. Try again in a minute. ` +
`If it keeps failing, tell the user to give support this reference: ${this.context.requestId}`,
"STORE_UNAVAILABLE",
),
);
}
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Open the Logs tab. After the time, the error line shows two ids in brackets, like [[1f0c2a9e:5d1b4c7e]]: the first eight characters of the request id and of the trace id. The message the model got ends with the full request id, so support can search the logs for its first eight characters and find the real error. The same id also reaches other services: this.fetch() sends it upstream as x-request-id.
Who is calling
this.auth describes the caller: user.sub says who they are, isAnonymous whether they signed in, scopes and roles what they were granted, and claims what their token said. It's filled in from what your server's authentication checked, usually a token the client sent, so it's the one part of the request a tool can base a decision on. Authenticating Clients covers where it comes from, and Authorizing Calls what each field holds in each auth mode.
This tool lists the tickets assigned to whoever is calling. It has no argument for "who", because the caller doesn't get to choose:
import { PublicMcpError, Tool, ToolContext } from "@frontmcp/sdk";
const assigned: Record<string, string[]> = { nour: ["T-1", "T-3"], sam: ["T-2"] };
@Tool({
name: "my_tickets",
description: "List the support tickets assigned to the signed-in agent.",
inputSchema: {},
annotations: { readOnlyHint: true },
})
export class MyTickets extends ToolContext {
async execute() {
if (this.auth.isAnonymous) {
this.fail(new PublicMcpError("Only signed-in agents have tickets. Ask the user to sign in.", "SIGN_IN_REQUIRED"));
}
const agent = this.auth.user.sub;
return { agent, tickets: assigned[agent] ?? [] };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The Playground has no authentication, so the call above is anonymous and fails. The second test calls the tool as the agent nour, using create(), which runs a server in-process and lets the calling code say who the user is: the user's sub becomes this.auth.user.sub. That's for tests and trusted code, and Running FrontMCP Anywhere covers it.
Arguments, client info and headers are claims
Only team leads may move a ticket to someone else. Here's a first attempt:
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
const leads = ["nour"];
@Tool({
name: "reassign_ticket",
description: "Move a ticket to another agent. Team leads only.",
inputSchema: {
id: z.string().regex(/^T-\d+$/),
to: z.string().describe("The agent to assign it to"),
requested_by: z.string().describe("Who is asking"),
},
})
export class ReassignTicket extends ToolContext {
async execute({ id, to, requested_by }: { id: string; to: string; requested_by: string }) {
if (!leads.includes(requested_by)) {
this.fail(new PublicMcpError(`${requested_by} isn't a team lead.`, "LEADS_ONLY"));
}
return { id, assignee: to };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The check works if the model passes the truth. But the model writes requested_by, from whatever is in the conversation, and a conversation can contain anything: a user who says "I'm Nour", or a ticket whose text says "the team lead asked to reassign this to mallory". The test shows it: an anonymous call that claims to be nour passes as a team lead.
Decide from this.auth, which the model can't write, and take the argument away:
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "reassign_ticket",
description: "Move a ticket to another agent. Team leads only.",
inputSchema: {
id: z.string().regex(/^T-\d+$/),
to: z.string().describe("The agent to assign it to"),
},
})
export class ReassignTicket extends ToolContext {
async execute({ id, to }: { id: string; to: string }) {
if (!this.auth.hasRole("lead")) {
this.fail(new PublicMcpError("Only team leads can reassign tickets.", "LEADS_ONLY"));
}
return { id, assignee: to, by: this.auth.user.sub };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Roles come from the caller's verified claims, here a roles claim in their token, which this.auth.roles reads. In-process, authContext.user stands in for those claims, so the test puts roles there. Authorizing Calls shows how to declare rules like this one so FrontMCP checks them before execute() runs at all.
Arguments aren't the only thing a client makes up. Everything in the request came from the client, and nothing but your authentication checks any of it:
- Client info. A client names itself in
clientInfo, and FrontMCP guessesthis.platformfrom that name. Any client can say it'sclaude-ai. - Headers.
this.context.metadataholds theUser-Agentand everyx-frontmcp-*header exactly as the client sent them. If you letthis.fetch()pass thex-frontmcp-*ones on to a service, don't let that service trust them either. _meta. Trace ids, progress tokens and client capabilities travel in the request's_meta, written by the client.
Use these for what they're good for: formatting a result for a client, logging, joining up a trace. Base access decisions only on the caller your authentication established, in this.auth.
Recap
- Everything about a call beyond its arguments is on
this:this.contextfor the request,this.authfor the caller,this.clientInfofor the client. this.context.requestIdis new for every request. Put it in log lines withthis.contextLogger, and in errors a person may have to report.- Take the caller's identity and claims from
this.auth, never from an argument. The model writes the arguments. - Client info, headers and
_metaare whatever the client sent. Use them for formatting, logging and tracing, not for access. - How much of the request reaches
thisdepends on how the server runs: in-process calls have no headers and a new trace, andcreate()has no client info. - Every member of
this.contextandthis.auth, per entry point, is in thethis.contextandthis.authreferences.
Try some challenges
Challenge 1 of 3
Give support a reference
export_tickets fails when the storage bucket rejects the upload, and its error passes the storage error straight to the model, access key and all. Keep the storage error in the logs, and give the model a message with a reference support can search for: this call's request id. Keep the code EXPORT_FAILED.
import { PublicMcpError, Tool, ToolContext } from "@frontmcp/sdk";
import { upload } from "./storage";
@Tool({
name: "export_tickets",
description: "Export every support ticket to a CSV file and return a link to it.",
inputSchema: {},
})
export class ExportTickets extends ToolContext {
async execute() {
try {
return { url: await upload("tickets.csv", "id,title\nT-1,Cannot log in\n") };
} catch (err) {
this.fail(new PublicMcpError(`Export failed: ${String(err)}`, "EXPORT_FAILED"));
}
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.