@prefecthq/fastmcp-ts 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1172,7 +1172,7 @@ var init_version = __esm({
1172
1172
  async run({ args }) {
1173
1173
  if (args.json) setJsonMode(true);
1174
1174
  const data = {
1175
- fastmcp: "1.3.0",
1175
+ fastmcp: "1.5.0",
1176
1176
  "mcp-sdk": "2.0.0",
1177
1177
  node: process.version,
1178
1178
  platform: `${process.platform} ${process.arch}`
@@ -33404,7 +33404,7 @@ var init_inspect = __esm({
33404
33404
  // only case Python's inspect supports); over --url/--command the
33405
33405
  // generation is unknowable from the wire.
33406
33406
  generation: mode.kind === "inprocess" ? 2 : null,
33407
- fastmcpVersion: "1.3.0",
33407
+ fastmcpVersion: "1.5.0",
33408
33408
  mcpVersion: "2.0.0",
33409
33409
  tools: tools2,
33410
33410
  prompts: prompts2,
@@ -46908,7 +46908,7 @@ init_format();
46908
46908
  var main = defineCommand({
46909
46909
  meta: {
46910
46910
  name: "fastmcp",
46911
- version: "1.3.0",
46911
+ version: "1.5.0",
46912
46912
  description: "FastMCP CLI \u2014 build, run, and manage MCP servers"
46913
46913
  },
46914
46914
  args: {
package/dist/server.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as _modelcontextprotocol_server from '@modelcontextprotocol/server';
2
2
  import { InputResponses, Server, CacheHint, ServerContext, ServerEventBus, Transport, McpHandlerRequestOptions, CallToolResult, OAuthClientInformationFull, AuthInfo } from '@modelcontextprotocol/server';
3
- export { InputRequest, InputRequests, InputRequiredResult, InputRequiredSpec, InputResponse, InputResponseView, InputResponses, ServerEventBus, acceptedContent, inputRequired, inputResponse, isInputRequiredResult } from '@modelcontextprotocol/server';
3
+ export { AuthInfo, InputRequest, InputRequests, InputRequiredResult, InputRequiredSpec, InputResponse, InputResponseView, InputResponses, McpHandlerRequestOptions, ServerEventBus, acceptedContent, inputRequired, inputResponse, isInputRequiredResult } from '@modelcontextprotocol/server';
4
4
  import { OAuthServerProvider, AuthorizationParams } from '@modelcontextprotocol/server-legacy/auth';
5
5
  import { StandardSchemaV1 } from '@standard-schema/spec';
6
6
  import { Readable, Writable } from 'node:stream';
@@ -407,6 +407,35 @@ interface McpContext {
407
407
  onClose(callback: () => void): void;
408
408
  }
409
409
 
410
+ /**
411
+ * Handler for a custom HTTP route served by the listener `run()` starts.
412
+ * Owns the whole exchange: no MCP auth, no DNS-rebinding guards, and no CORS
413
+ * headers run in front of it, and its Response is written back verbatim.
414
+ */
415
+ type CustomRouteHandler = (request: Request) => Response | Promise<Response>;
416
+ interface CustomRouteConfig {
417
+ /** Exact request path to serve; the query string is ignored for matching.
418
+ * Must start with `/`. No patterns, no path parameters. */
419
+ path: string;
420
+ /**
421
+ * HTTP methods served, compared case-insensitively. Default: `['GET']`.
422
+ * `OPTIONS` is rejected at registration: the global CORS preflight answers
423
+ * `OPTIONS` before route dispatch, so a route on it would be unreachable.
424
+ */
425
+ methods?: string[];
426
+ }
427
+ /** `RunOptions.health` object form. Presence implies enabled. */
428
+ interface HealthOptions {
429
+ /** Force off with `false`; the object's presence already implies `true`. */
430
+ enabled?: boolean;
431
+ /** Request path of the endpoint. Default: `/healthz`. */
432
+ path?: string;
433
+ /** Response status code. Default: `200`. */
434
+ status?: number;
435
+ /** Response body, served as `text/plain`. Default: `'ok'`. */
436
+ body?: string;
437
+ }
438
+
410
439
  interface MiddlewareContext<T = unknown> {
411
440
  readonly method: string;
412
441
  readonly request: T;
@@ -871,11 +900,14 @@ interface FastMCPOptions {
871
900
  */
872
901
  eventBus?: ServerEventBus;
873
902
  /**
874
- * DNS-rebinding protection for the HTTP transport: validates the `Host` and
875
- * `Origin` request headers (port-agnostic, by hostname) and rejects mismatches
876
- * with `403`. Defends localhost servers against a malicious web page whose DNS
877
- * rebinds to `127.0.0.1` (MCP transport security best practice). Only affects the
878
- * HTTP transport — stdio is unaffected.
903
+ * DNS-rebinding protection for the HTTP listener started by `run()`: validates
904
+ * the `Host` and `Origin` request headers (port-agnostic, by hostname) and rejects
905
+ * mismatches with `403`. Defends localhost servers against a malicious web page
906
+ * whose DNS rebinds to `127.0.0.1` (MCP transport security best practice).
907
+ *
908
+ * This option does not apply to `fetch()`, which has no bind address from which
909
+ * to infer a trusted host. A framework embedding `fetch()` owns Host/Origin
910
+ * validation at its HTTP boundary. stdio is unaffected.
879
911
  *
880
912
  * Default posture (option omitted): protection auto-enables when, and only when,
881
913
  * `run()` binds the HTTP server to a loopback host (`127.0.0.1`, `::1`,
@@ -918,6 +950,18 @@ interface RunOptions {
918
950
  * is already stateless and is unaffected.
919
951
  */
920
952
  stateless?: boolean;
953
+ /**
954
+ * Health endpoint on the HTTP listener `run()` starts — for Kubernetes
955
+ * liveness/readiness probes and load-balancer checks. Off unless supplied:
956
+ * `true` (or any object) enables it with defaults `path: '/healthz'`,
957
+ * `status: 200`, `body: 'ok'` (`text/plain`); `enabled: false` (or `false`)
958
+ * forces it off. Served before MCP auth, CORS, and the DNS-rebinding
959
+ * guards, so probes need no credentials and may send a pod-IP Host header.
960
+ * Sugar over `customRoute()` — registering your own route on the same path
961
+ * is a startup error. Ignored on stdio (a malformed value still aborts
962
+ * startup, like `stateless`) and never served through `fetch()`.
963
+ */
964
+ health?: boolean | HealthOptions;
921
965
  /** Custom stdin stream for the stdio transport. Defaults to process.stdin. */
922
966
  stdin?: Readable;
923
967
  /** Custom stdout stream for the stdio transport. Defaults to process.stdout. */
@@ -928,11 +972,31 @@ interface ServerAddress {
928
972
  port: number;
929
973
  path: string;
930
974
  }
975
+ /**
976
+ * Behavioral hints for a tool (MCP 2025-03-26). Advisory only — clients may use
977
+ * them for UI/UX and safety decisions (e.g. auto-approving a read-only call); the
978
+ * server does not enforce them. Per the MCP spec, annotations from an untrusted
979
+ * server should not be relied upon.
980
+ */
981
+ interface ToolAnnotations {
982
+ /** Human-readable title for display. */
983
+ title?: string;
984
+ /** The tool does not modify its environment. Default: false. */
985
+ readOnlyHint?: boolean;
986
+ /** The tool may perform destructive updates. Only meaningful when readOnlyHint is false. Default: true. */
987
+ destructiveHint?: boolean;
988
+ /** Repeated calls with the same arguments have no additional effect. Only meaningful when readOnlyHint is false. Default: false. */
989
+ idempotentHint?: boolean;
990
+ /** The tool may interact with an open world of external entities. Default: true. */
991
+ openWorldHint?: boolean;
992
+ }
931
993
  interface ToolConfig {
932
994
  name: string;
933
995
  /** Human-readable display name shown in UIs. Takes precedence over `name` for display purposes. */
934
996
  title?: string;
935
997
  description: string;
998
+ /** Behavioral hints for clients (MCP 2025-03-26). Forwarded verbatim in tools/list. */
999
+ annotations?: ToolAnnotations;
936
1000
  /** Standard Schema validator for the tool's input arguments. Used for runtime validation. */
937
1001
  input?: StandardSchemaV1;
938
1002
  /**
@@ -970,6 +1034,7 @@ declare class FastMCP {
970
1034
  private _staticResources;
971
1035
  private _templateResources;
972
1036
  private _prompts;
1037
+ private _customRoutes;
973
1038
  private _promptsPageSize;
974
1039
  private _middleware;
975
1040
  private _transforms;
@@ -1082,6 +1147,15 @@ declare class FastMCP {
1082
1147
  tool(config: ToolConfig, handler: (args: Record<string, unknown>) => unknown): void;
1083
1148
  prompt(config: PromptConfig, handler: (args?: Record<string, string>) => unknown): void;
1084
1149
  resource(config: ResourceConfig, handler: (params?: Record<string, string>) => unknown): void;
1150
+ /**
1151
+ * Register a custom HTTP route on the listener `run()` starts — e.g. a
1152
+ * Kubernetes liveness/readiness probe. The handler owns the whole exchange:
1153
+ * no MCP auth, no DNS-rebinding guards, and no CORS headers run in front of
1154
+ * it. Matching is exact-path (query string ignored); methods default to GET.
1155
+ * Routes never serve on stdio or through `fetch()` — a framework embedding
1156
+ * `fetch()` owns its own routing (same posture as `dnsRebinding`).
1157
+ */
1158
+ customRoute(config: CustomRouteConfig, handler: CustomRouteHandler): void;
1085
1159
  _removeTool(name: string): boolean;
1086
1160
  _removeResource(uri: string): boolean;
1087
1161
  _removePrompt(name: string): boolean;
@@ -1249,6 +1323,19 @@ declare class FastMCP {
1249
1323
  */
1250
1324
  get isRunning(): boolean;
1251
1325
  connect(transport: Transport): Promise<void>;
1326
+ /**
1327
+ * Serve an MCP request through a web-standard, fetch-native HTTP entrypoint.
1328
+ *
1329
+ * This entrypoint is always stateless and does not require `run()`: legacy
1330
+ * (2025-era) requests use a fresh server instance per request, while modern
1331
+ * (2026-07-28) requests share the instance's modern handler and event bus.
1332
+ * Authentication is deliberately external — `options.authInfo` is trusted and
1333
+ * passed unchanged into the protocol handler, where it is exposed as `ctx.auth`.
1334
+ * The embedding framework also owns Host/Origin validation and should abort its
1335
+ * carrying requests during shutdown; stateless legacy response streams follow
1336
+ * that request lifecycle, while `close()` terminates modern exchanges and streams.
1337
+ */
1338
+ fetch(request: Request, options?: McpHandlerRequestOptions): Promise<Response>;
1252
1339
  /**
1253
1340
  * Modern-era (2026-07-28) in-process fetch entrypoint — the `McpServerLike`
1254
1341
  * duck-type hook `fastmcp-ts/client`'s `Client` looks for when a caller pins
@@ -1265,9 +1352,9 @@ declare class FastMCP {
1265
1352
  /** Lazily builds the modern (2026-07-28) HTTP handler. One per FastMCP instance;
1266
1353
  * builds a fresh Server (via _makeServer) per request, matching createMcpHandler's
1267
1354
  * per-request-factory model. Modern-only (legacy: 'reject') — legacy (2025-era)
1268
- * traffic is routed to the existing sessionful transport by _dispatchHttp instead
1269
- * of createMcpHandler's own stateless legacy fallback, so session state and the
1270
- * legacy server-initiated-request shim keep working for 2025-era clients. */
1355
+ * traffic is routed separately: to the existing sessionful transport by
1356
+ * _dispatchHttp, or to the stateless fallback by fetch(). This preserves the
1357
+ * session state and server-initiated-request shim for run()'s default HTTP path. */
1271
1358
  private _getModernHandler;
1272
1359
  /** Lazily builds the stateless legacy (2025-era) HTTP handler. One per FastMCP
1273
1360
  * instance, mirroring _getModernHandler's per-instance, per-request-factory model.
@@ -1276,8 +1363,9 @@ declare class FastMCP {
1276
1363
  * built with `sessionIdGenerator: undefined`, and answers GET and DELETE with 405
1277
1364
  * because both are session operations that mean nothing per request.
1278
1365
  *
1279
- * Only reachable when `_stateless` is on. The sessionful transport in
1280
- * _dispatchLegacyHttp is untouched and still serves every other deployment. */
1366
+ * Used unconditionally by fetch(), and by run() only when `_stateless` is on.
1367
+ * The sessionful transport in _dispatchLegacyHttp is untouched and remains the
1368
+ * default for listener-based HTTP deployments. */
1281
1369
  private _getStatelessLegacyHandler;
1282
1370
  /**
1283
1371
  * Dual-era HTTP dispatch shared by the OAuth and non-OAuth serve paths. Assumes CORS
@@ -1753,4 +1841,4 @@ declare class FormInput {
1753
1841
  constructor(options: FormInputOptions);
1754
1842
  }
1755
1843
 
1756
- export { type AccessToken, Approval, Area, type AuthCheck, AuthorizationError, type BackendToolConfig, Badge, Bar, type BrowserPermissions, Button, type CacheKeyFn, CachingMiddleware, CancellationMiddleware, type CatalogEntry, Choice, Column, type CompleteCallback, type CompletionContext, type CompletionResult, type Component, type ComponentFn, type CspPolicy, type ElicitationResult, type ElicitationSchema, type EntrypointConfig, ErrorNormalizationMiddleware, FastMCP, FastMCPApp, type FastMCPAppOptions, type FastMCPOptions, File, type FileHandle, type FileStorageAdapter, FileUpload, type FileUploadOptions, FilterTransform, ForEach, FormInput, type FormInputOptions, GenerativeUI, Grid, type HTTPRoute, type HttpMethod, type HttpRequestContext, If, type IfNode, Image, Input, Line, type LogLevel, LoggingMiddleware, type MCPType, type McpContext, type Middleware, type MiddlewareContext, NamespaceTransform, type Next, type OAuthConfig, type OAuthProviderOptions, type OAuthProxyOptions, type OpenAPIClientOptions, type OpenAPIComponent, type OpenAPIResourceComponent, type OpenAPIResourceTemplateComponent, type OpenAPIServerOptions, type OpenAPIToolComponent, type ParameterInfo, Pie, type PromptArgument, type PromptConfig, type PromptContent, type PromptMessage, PromptResult, type PromptView, PromptsAsTools, type ProxyTransport, RateLimitingMiddleware, type RequestBodyInfo, type RequestVerifier, type ResourceAnnotations, type ResourceConfig, ResourceResult, type ResourceUiMeta, type ResourceView, ResourcesAsTools, type ResponseInfo, type Root, type RouteMap, type RouteMapFn, Row, type RunOptions, Rx, type SamplingMessage, type SamplingParams, type SamplingResult, Select, type ServerAddress, SizeLimitingMiddleware, type SynthesizedTool, Table, Text, type TokenVerifier, type ToolConfig, ToolResult, type ToolView, type Transform, type UiToolMeta, VersionFilter, type Visibility, actionRef, createOpenAPIServer, createProxy, debugTokenVerifier, forwardableHeaders, introspectionVerifier, jwtVerifier, multiAuth, oauthProvider, oauthProxy, redescribeTool, renameTool, requireScopes, staticTokenVerifier };
1844
+ export { type AccessToken, Approval, Area, type AuthCheck, AuthorizationError, type BackendToolConfig, Badge, Bar, type BrowserPermissions, Button, type CacheKeyFn, CachingMiddleware, CancellationMiddleware, type CatalogEntry, Choice, Column, type CompleteCallback, type CompletionContext, type CompletionResult, type Component, type ComponentFn, type CspPolicy, type CustomRouteConfig, type CustomRouteHandler, type ElicitationResult, type ElicitationSchema, type EntrypointConfig, ErrorNormalizationMiddleware, FastMCP, FastMCPApp, type FastMCPAppOptions, type FastMCPOptions, File, type FileHandle, type FileStorageAdapter, FileUpload, type FileUploadOptions, FilterTransform, ForEach, FormInput, type FormInputOptions, GenerativeUI, Grid, type HTTPRoute, type HealthOptions, type HttpMethod, type HttpRequestContext, If, type IfNode, Image, Input, Line, type LogLevel, LoggingMiddleware, type MCPType, type McpContext, type Middleware, type MiddlewareContext, NamespaceTransform, type Next, type OAuthConfig, type OAuthProviderOptions, type OAuthProxyOptions, type OpenAPIClientOptions, type OpenAPIComponent, type OpenAPIResourceComponent, type OpenAPIResourceTemplateComponent, type OpenAPIServerOptions, type OpenAPIToolComponent, type ParameterInfo, Pie, type PromptArgument, type PromptConfig, type PromptContent, type PromptMessage, PromptResult, type PromptView, PromptsAsTools, type ProxyTransport, RateLimitingMiddleware, type RequestBodyInfo, type RequestVerifier, type ResourceAnnotations, type ResourceConfig, ResourceResult, type ResourceUiMeta, type ResourceView, ResourcesAsTools, type ResponseInfo, type Root, type RouteMap, type RouteMapFn, Row, type RunOptions, Rx, type SamplingMessage, type SamplingParams, type SamplingResult, Select, type ServerAddress, SizeLimitingMiddleware, type SynthesizedTool, Table, Text, type TokenVerifier, type ToolAnnotations, type ToolConfig, ToolResult, type ToolView, type Transform, type UiToolMeta, VersionFilter, type Visibility, actionRef, createOpenAPIServer, createProxy, debugTokenVerifier, forwardableHeaders, introspectionVerifier, jwtVerifier, multiAuth, oauthProvider, oauthProxy, redescribeTool, renameTool, requireScopes, staticTokenVerifier };
package/dist/server.js CHANGED
@@ -281,6 +281,106 @@ function createContext(server, sdkCtx, auth, sessionState, requestStateCodec, st
281
281
  };
282
282
  }
283
283
 
284
+ // src/server/customRoutes.ts
285
+ import { Readable } from "stream";
286
+ var CustomRouteRegistry = class {
287
+ _routes = /* @__PURE__ */ new Map();
288
+ register(config, handler) {
289
+ const path = config?.path;
290
+ if (typeof path !== "string" || !path.startsWith("/")) {
291
+ throw new Error(`customRoute path must be a string starting with "/", got: ${JSON.stringify(path)}`);
292
+ }
293
+ const methods = (config.methods ?? ["GET"]).map((m) => m.toUpperCase());
294
+ if (methods.length === 0) {
295
+ throw new Error(`customRoute ${path}: methods must not be empty (omit it for the GET default)`);
296
+ }
297
+ if (methods.includes("OPTIONS")) {
298
+ throw new Error(
299
+ `customRoute ${path}: OPTIONS cannot be routed \u2014 the CORS preflight handler answers OPTIONS before route dispatch`
300
+ );
301
+ }
302
+ if (this._routes.has(path)) {
303
+ throw new Error(`customRoute already registered for path ${path}`);
304
+ }
305
+ this._routes.set(path, { methods: new Set(methods), handler });
306
+ }
307
+ /** Throws when a registered route sits on the MCP endpoint path. Called at HTTP startup. */
308
+ assertNoMcpCollision(mcpPath) {
309
+ if (this._routes.has(mcpPath)) {
310
+ throw new Error(
311
+ `customRoute path ${mcpPath} collides with the MCP endpoint path; move the route or the MCP \`path\``
312
+ );
313
+ }
314
+ }
315
+ /** Match a request path + method. OPTIONS never matches (preflight owns it). */
316
+ match(pathname, method) {
317
+ const route = this._routes.get(pathname);
318
+ if (!route) return null;
319
+ const upper = method.toUpperCase();
320
+ if (upper === "OPTIONS") return null;
321
+ if (route.methods.has(upper)) return { kind: "handler", handler: route.handler };
322
+ return { kind: "method-mismatch", allow: [...route.methods].join(", ") };
323
+ }
324
+ paths() {
325
+ return [...this._routes.keys()];
326
+ }
327
+ get size() {
328
+ return this._routes.size;
329
+ }
330
+ };
331
+ var NULL_BODY_STATUSES = /* @__PURE__ */ new Set([101, 204, 205, 304]);
332
+ function resolveHealth(health) {
333
+ if (health === void 0 || health === false) return null;
334
+ if (health === true) return { path: "/healthz", status: 200, body: "ok" };
335
+ if (typeof health !== "object" || health === null) {
336
+ throw new Error(`Invalid health option: expected boolean or object, got ${typeof health}`);
337
+ }
338
+ if (health.enabled === false) return null;
339
+ const path = health.path ?? "/healthz";
340
+ if (typeof path !== "string" || !path.startsWith("/")) {
341
+ throw new Error(`Invalid health.path: must be a string starting with "/", got: ${JSON.stringify(health.path)}`);
342
+ }
343
+ const status = health.status ?? 200;
344
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
345
+ throw new Error(`Invalid health.status: must be an integer HTTP status code, got: ${JSON.stringify(health.status)}`);
346
+ }
347
+ const body = health.body ?? "ok";
348
+ if (typeof body !== "string") {
349
+ throw new Error(`Invalid health.body: must be a string, got: ${typeof health.body}`);
350
+ }
351
+ if (body !== "" && NULL_BODY_STATUSES.has(status)) {
352
+ throw new Error(
353
+ `Invalid health config: status ${status} cannot carry a response body (the fetch spec forbids one on 101/204/205/304); set body: '' or choose a different status`
354
+ );
355
+ }
356
+ return { path, status, body };
357
+ }
358
+ async function serveCustomRouteNode(handler, req, res) {
359
+ const { toWebRequest } = await import("@modelcontextprotocol/node");
360
+ let response;
361
+ try {
362
+ response = await handler(await toWebRequest(req));
363
+ } catch (error) {
364
+ console.error("[fastmcp] custom route handler failed:", error);
365
+ res.writeHead(500, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "Internal Server Error" }));
366
+ return;
367
+ }
368
+ res.writeHead(response.status, Object.fromEntries(response.headers));
369
+ if (response.body) {
370
+ const stream = Readable.fromWeb(response.body);
371
+ stream.on("error", (error) => {
372
+ console.error("[fastmcp] custom route response stream failed:", error);
373
+ res.destroy();
374
+ });
375
+ stream.pipe(res);
376
+ } else {
377
+ res.end();
378
+ }
379
+ }
380
+ function writeMethodNotAllowed(res, allow) {
381
+ res.writeHead(405, { Allow: allow, "Content-Type": "application/json" }).end(JSON.stringify({ error: "Method Not Allowed" }));
382
+ }
383
+
284
384
  // src/server/env.ts
285
385
  var TRUE_VALUES = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
286
386
  var FALSE_VALUES = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
@@ -952,6 +1052,7 @@ var FastMCP = class {
952
1052
  _staticResources = /* @__PURE__ */ new Map();
953
1053
  _templateResources = /* @__PURE__ */ new Map();
954
1054
  _prompts = /* @__PURE__ */ new Map();
1055
+ _customRoutes = new CustomRouteRegistry();
955
1056
  _promptsPageSize;
956
1057
  _middleware;
957
1058
  _transforms;
@@ -1135,6 +1236,7 @@ var FastMCP = class {
1135
1236
  description: entry.description,
1136
1237
  inputSchema,
1137
1238
  ...outputSchema ? { outputSchema } : {},
1239
+ ...t.annotations ? { annotations: t.annotations } : {},
1138
1240
  ...uiMeta ? { _meta: { ui: uiMeta } } : {}
1139
1241
  };
1140
1242
  })
@@ -1748,6 +1850,17 @@ var FastMCP = class {
1748
1850
  this._notifyResourceListChanged();
1749
1851
  for (const cb of this._resourceRegisteredCallbacks) cb(registered);
1750
1852
  }
1853
+ /**
1854
+ * Register a custom HTTP route on the listener `run()` starts — e.g. a
1855
+ * Kubernetes liveness/readiness probe. The handler owns the whole exchange:
1856
+ * no MCP auth, no DNS-rebinding guards, and no CORS headers run in front of
1857
+ * it. Matching is exact-path (query string ignored); methods default to GET.
1858
+ * Routes never serve on stdio or through `fetch()` — a framework embedding
1859
+ * `fetch()` owns its own routing (same posture as `dnsRebinding`).
1860
+ */
1861
+ customRoute(config, handler) {
1862
+ this._customRoutes.register(config, handler);
1863
+ }
1751
1864
  _removeTool(name) {
1752
1865
  if (!this._tools.has(name)) return false;
1753
1866
  this._tools.delete(name);
@@ -2111,6 +2224,29 @@ var FastMCP = class {
2111
2224
  this._primaryServer = this._makeServer();
2112
2225
  await this._primaryServer.connect(transport);
2113
2226
  }
2227
+ /**
2228
+ * Serve an MCP request through a web-standard, fetch-native HTTP entrypoint.
2229
+ *
2230
+ * This entrypoint is always stateless and does not require `run()`: legacy
2231
+ * (2025-era) requests use a fresh server instance per request, while modern
2232
+ * (2026-07-28) requests share the instance's modern handler and event bus.
2233
+ * Authentication is deliberately external — `options.authInfo` is trusted and
2234
+ * passed unchanged into the protocol handler, where it is exposed as `ctx.auth`.
2235
+ * The embedding framework also owns Host/Origin validation and should abort its
2236
+ * carrying requests during shutdown; stateless legacy response streams follow
2237
+ * that request lifecycle, while `close()` terminates modern exchanges and streams.
2238
+ */
2239
+ async fetch(request, options) {
2240
+ if (request.method.toUpperCase() === "POST" && !isJsonContentType(request.headers.get("content-type"))) {
2241
+ return new Response(
2242
+ JSON.stringify({ error: "Unsupported Media Type: expected application/json" }),
2243
+ { status: 415, headers: { "Content-Type": "application/json" } }
2244
+ );
2245
+ }
2246
+ const legacy = await isLegacyRequest(request, options?.parsedBody);
2247
+ if (legacy) return this._getStatelessLegacyHandler()(request, options);
2248
+ return this._getModernHandler().fetch(request, options);
2249
+ }
2114
2250
  /**
2115
2251
  * Modern-era (2026-07-28) in-process fetch entrypoint — the `McpServerLike`
2116
2252
  * duck-type hook `fastmcp-ts/client`'s `Client` looks for when a caller pins
@@ -2136,6 +2272,7 @@ var FastMCP = class {
2136
2272
  const host = options?.host ?? process.env.MCP_HOST ?? "127.0.0.1";
2137
2273
  const path = options?.path ?? process.env.MCP_PATH ?? "/mcp";
2138
2274
  this._stateless = options?.stateless ?? envBool("FASTMCP_STATELESS_HTTP") ?? false;
2275
+ const resolvedHealth = resolveHealth(options?.health);
2139
2276
  if (transport === "stdio") {
2140
2277
  const { StdioServerTransport, serveStdio } = await import("@modelcontextprotocol/server/stdio");
2141
2278
  const stdioTransport = new StdioServerTransport(options?.stdin, options?.stdout);
@@ -2148,18 +2285,36 @@ var FastMCP = class {
2148
2285
  },
2149
2286
  { transport: stdioTransport }
2150
2287
  );
2151
- } else if (this._oauth) {
2152
- await this._runHttpOAuth(port, host, path);
2153
2288
  } else {
2154
- await this._runHttpSimple(port, host, path);
2289
+ if (resolvedHealth) {
2290
+ this._customRoutes.register(
2291
+ { path: resolvedHealth.path },
2292
+ () => (
2293
+ // An empty body maps to a null Response body (no Content-Type either): the
2294
+ // fetch spec forbids any body, even '', on 101/204/205/304, and resolveHealth
2295
+ // already rejected a non-empty body on those statuses — so branching on the
2296
+ // body alone is enough to keep this call from throwing.
2297
+ resolvedHealth.body === "" ? new Response(null, { status: resolvedHealth.status }) : new Response(resolvedHealth.body, {
2298
+ status: resolvedHealth.status,
2299
+ headers: { "Content-Type": "text/plain" }
2300
+ })
2301
+ )
2302
+ );
2303
+ }
2304
+ this._customRoutes.assertNoMcpCollision(path);
2305
+ if (this._oauth) {
2306
+ await this._runHttpOAuth(port, host, path);
2307
+ } else {
2308
+ await this._runHttpSimple(port, host, path);
2309
+ }
2155
2310
  }
2156
2311
  }
2157
2312
  /** Lazily builds the modern (2026-07-28) HTTP handler. One per FastMCP instance;
2158
2313
  * builds a fresh Server (via _makeServer) per request, matching createMcpHandler's
2159
2314
  * per-request-factory model. Modern-only (legacy: 'reject') — legacy (2025-era)
2160
- * traffic is routed to the existing sessionful transport by _dispatchHttp instead
2161
- * of createMcpHandler's own stateless legacy fallback, so session state and the
2162
- * legacy server-initiated-request shim keep working for 2025-era clients. */
2315
+ * traffic is routed separately: to the existing sessionful transport by
2316
+ * _dispatchHttp, or to the stateless fallback by fetch(). This preserves the
2317
+ * session state and server-initiated-request shim for run()'s default HTTP path. */
2163
2318
  _getModernHandler() {
2164
2319
  if (!this._modernHandler) {
2165
2320
  this._modernHandler = createMcpHandler(() => this._makeServer(/* @__PURE__ */ new Map(), { modern: true }), {
@@ -2176,8 +2331,9 @@ var FastMCP = class {
2176
2331
  * built with `sessionIdGenerator: undefined`, and answers GET and DELETE with 405
2177
2332
  * because both are session operations that mean nothing per request.
2178
2333
  *
2179
- * Only reachable when `_stateless` is on. The sessionful transport in
2180
- * _dispatchLegacyHttp is untouched and still serves every other deployment. */
2334
+ * Used unconditionally by fetch(), and by run() only when `_stateless` is on.
2335
+ * The sessionful transport in _dispatchLegacyHttp is untouched and remains the
2336
+ * default for listener-based HTTP deployments. */
2181
2337
  _getStatelessLegacyHandler() {
2182
2338
  if (!this._statelessLegacyHandler) {
2183
2339
  this._statelessLegacyHandler = legacyStatelessFallback(
@@ -2285,6 +2441,12 @@ var FastMCP = class {
2285
2441
  const { requireBearerAuth } = await import("@modelcontextprotocol/server-legacy/auth");
2286
2442
  const oauth = this._oauth;
2287
2443
  const app = express();
2444
+ app.use((req, res, next) => {
2445
+ const match = this._customRoutes.match(req.path, req.method ?? "");
2446
+ if (!match) return next();
2447
+ if (match.kind === "method-mismatch") return writeMethodNotAllowed(res, match.allow);
2448
+ void serveCustomRouteNode(match.handler, req, res);
2449
+ });
2288
2450
  const httpServer = await new Promise((resolve, reject) => {
2289
2451
  const srv = app.listen(port, host, () => resolve(srv));
2290
2452
  srv.on("error", reject);
@@ -2328,7 +2490,14 @@ var FastMCP = class {
2328
2490
  res.writeHead(204, corsHeaders).end();
2329
2491
  return;
2330
2492
  }
2331
- if (req.url?.split("?")[0] !== path) {
2493
+ const pathname = req.url?.split("?")[0] ?? "";
2494
+ const routeMatch = this._customRoutes.match(pathname, req.method ?? "");
2495
+ if (routeMatch) {
2496
+ if (routeMatch.kind === "method-mismatch") writeMethodNotAllowed(res, routeMatch.allow);
2497
+ else await serveCustomRouteNode(routeMatch.handler, req, res);
2498
+ return;
2499
+ }
2500
+ if (pathname !== path) {
2332
2501
  res.writeHead(404).end();
2333
2502
  return;
2334
2503
  }
@@ -2986,7 +3155,7 @@ var OPENAPI_SPECIFIC_FIELDS = [
2986
3155
  var RECURSIVE_MAP_FIELDS = ["properties", "$defs", "$definitions"];
2987
3156
  var RECURSIVE_SINGLE_FIELDS = ["items", "additionalProperties", "not"];
2988
3157
  var RECURSIVE_LIST_FIELDS = ["allOf", "anyOf", "oneOf"];
2989
- function convertOpenAPISchemaToJsonSchema(schema, openapiVersion) {
3158
+ function convertOpenAPISchemaToJsonSchema(schema, openapiVersion, options = {}) {
2990
3159
  if (!isPlainObject(schema)) return schema;
2991
3160
  let result = { ...schema };
2992
3161
  if (openapiVersion?.startsWith("3.0")) {
@@ -3000,13 +3169,16 @@ function convertOpenAPISchemaToJsonSchema(schema, openapiVersion) {
3000
3169
  for (const field of OPENAPI_SPECIFIC_FIELDS) {
3001
3170
  delete result[field];
3002
3171
  }
3172
+ if (options.removeReadOnly === true || options.removeWriteOnly === true) {
3173
+ result = filterPropertiesByAccess(result, options);
3174
+ }
3003
3175
  for (const field of RECURSIVE_MAP_FIELDS) {
3004
3176
  const map = result[field];
3005
3177
  if (isPlainObject(map)) {
3006
3178
  result[field] = Object.fromEntries(
3007
3179
  Object.entries(map).map(([name, sub]) => [
3008
3180
  name,
3009
- isPlainObject(sub) ? convertOpenAPISchemaToJsonSchema(sub, openapiVersion) : sub
3181
+ isPlainObject(sub) ? convertOpenAPISchemaToJsonSchema(sub, openapiVersion, options) : sub
3010
3182
  ])
3011
3183
  );
3012
3184
  }
@@ -3014,14 +3186,14 @@ function convertOpenAPISchemaToJsonSchema(schema, openapiVersion) {
3014
3186
  for (const field of RECURSIVE_SINGLE_FIELDS) {
3015
3187
  const sub = result[field];
3016
3188
  if (isPlainObject(sub)) {
3017
- result[field] = convertOpenAPISchemaToJsonSchema(sub, openapiVersion);
3189
+ result[field] = convertOpenAPISchemaToJsonSchema(sub, openapiVersion, options);
3018
3190
  }
3019
3191
  }
3020
3192
  for (const field of RECURSIVE_LIST_FIELDS) {
3021
3193
  const list = result[field];
3022
3194
  if (Array.isArray(list)) {
3023
3195
  result[field] = list.map(
3024
- (item) => isPlainObject(item) ? convertOpenAPISchemaToJsonSchema(item, openapiVersion) : item
3196
+ (item) => isPlainObject(item) ? convertOpenAPISchemaToJsonSchema(item, openapiVersion, options) : item
3025
3197
  );
3026
3198
  }
3027
3199
  }
@@ -3082,6 +3254,28 @@ function requireDiscriminatorProperty(schema) {
3082
3254
  }
3083
3255
  return result;
3084
3256
  }
3257
+ function filterPropertiesByAccess(schema, options) {
3258
+ const properties = schema.properties;
3259
+ if (!isPlainObject(properties)) return schema;
3260
+ const removed = /* @__PURE__ */ new Set();
3261
+ for (const [name, prop] of Object.entries(properties)) {
3262
+ if (!isPlainObject(prop)) continue;
3263
+ if (options.removeReadOnly === true && truthy(prop.readOnly) || options.removeWriteOnly === true && truthy(prop.writeOnly)) {
3264
+ removed.add(name);
3265
+ }
3266
+ }
3267
+ if (removed.size === 0) return schema;
3268
+ const result = { ...schema };
3269
+ result.properties = Object.fromEntries(
3270
+ Object.entries(properties).filter(([name]) => !removed.has(name))
3271
+ );
3272
+ if (Array.isArray(result.required)) {
3273
+ result.required = result.required.filter(
3274
+ (name) => typeof name !== "string" || !removed.has(name)
3275
+ );
3276
+ }
3277
+ return result;
3278
+ }
3085
3279
 
3086
3280
  // src/server/openapi/schemas.ts
3087
3281
  var EXTERNAL_REF_MESSAGE = "External or non-local reference not supported";
@@ -3351,7 +3545,9 @@ function combineSchemasAndMapParams(route) {
3351
3545
  result.$defs = route.requestSchemas;
3352
3546
  }
3353
3547
  if (route.openapiVersion?.startsWith("3")) {
3354
- result = convertOpenAPISchemaToJsonSchema(result, route.openapiVersion);
3548
+ result = convertOpenAPISchemaToJsonSchema(result, route.openapiVersion, {
3549
+ removeReadOnly: true
3550
+ });
3355
3551
  }
3356
3552
  return [result, parameterMap];
3357
3553
  }
@@ -3404,7 +3600,9 @@ function extractOutputSchemaFromResponses(responses, schemaDefinitions, openapiV
3404
3600
  }
3405
3601
  }
3406
3602
  if (openapiVersion?.startsWith("3")) {
3407
- outputSchema = convertOpenAPISchemaToJsonSchema(outputSchema, openapiVersion);
3603
+ outputSchema = convertOpenAPISchemaToJsonSchema(outputSchema, openapiVersion, {
3604
+ removeWriteOnly: true
3605
+ });
3408
3606
  }
3409
3607
  if (outputSchema.type !== "object") {
3410
3608
  outputSchema = {
@@ -3423,7 +3621,9 @@ function extractOutputSchemaFromResponses(responses, schemaDefinitions, openapiV
3423
3621
  }
3424
3622
  if (openapiVersion?.startsWith("3")) {
3425
3623
  for (const name of Object.keys(processedDefs)) {
3426
- processedDefs[name] = convertOpenAPISchemaToJsonSchema(processedDefs[name], openapiVersion);
3624
+ processedDefs[name] = convertOpenAPISchemaToJsonSchema(processedDefs[name], openapiVersion, {
3625
+ removeWriteOnly: true
3626
+ });
3427
3627
  }
3428
3628
  }
3429
3629
  outputSchema.$defs = processedDefs;