Skip to content

Conversation

@laulauland
Copy link
Member

@laulauland laulauland commented Oct 23, 2025

Changes

Adds generic TConfig parameter to McpServer class, enabling type-safe state access in middleware and handlers.

Breaking Change

⚠️ The ctx.env property has been removed and replaced with ctx.state.

Migration: Rename all ctx.env references to ctx.state.

// Before
server.use(async (ctx, next) => {
  ctx.env.userId = "123";
  await next();
});

// After
server.use(async (ctx, next) => {
  ctx.state.userId = "123";
  await next();
});

Implementation

  • McpServer<TConfig> accepts generic config with State property
  • MCPServerContext<TConfig> threads type through to ctx.state
  • All handler types (MethodHandler, ToolEntry, PromptEntry, ResourceEntry, ResourceHandler, PromptHandler) propagate TConfig
  • createContext<TConfig>() properly types the state object
  • Default TConfig = { State: Record<string, unknown> } maintains type flexibility

Files Modified

  • packages/core/src/core.ts: Add TConfig generic to McpServer class
  • packages/core/src/context.ts: Add TConfig generic to createContext, remove env property
  • packages/core/src/types.ts: Update all handler and entry types with TConfig, replace env with state
  • examples/typed-state-example.ts: Example demonstrating typed state usage

Example

interface MyAppState {
  userId?: string;
  requestCount: number;
}

const server = new McpServer<{ State: MyAppState }>({
  name: "my-server",
  version: "1.0.0",
});

server.use(async (ctx, next) => {
  ctx.state.requestCount = (ctx.state.requestCount || 0) + 1;
  // TypeScript error: ctx.state.requestCount = "not a number"; ❌
  await next();
});

Introduces generic TConfig parameter to McpServer, enabling type-safe
state access across middleware and handlers. Maintains backward compatibility
with default Record<string, unknown> state type.
@changeset-bot
Copy link

changeset-bot bot commented Oct 23, 2025

🦋 Changeset detected

Latest commit: 6e2ad10

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
mcp-lite Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Oct 23, 2025

Open in StackBlitz

npm i https://pkg.pr.new/fiberplane/mcp-lite@145

commit: 6e2ad10

@flenter
Copy link
Member

flenter commented Oct 23, 2025

What about using a withState() builder pattern? The api for the user would be something like:

// ✅ Chainable, clean inference
const server = new McpServer({ name: "app", version: "1.0.0" })
  .withState(() => ({
    count: 0,
    user: null as string | null
  }))
  .use((ctx, next) => {
    ctx.state.count += 1; // ✅ Typed
    await next();
  })
  .tool("getCount", {
    handler: (_, ctx) => ({
      content: [{ type: "text", text: String(ctx.state.count) }] // ✅ Typed
    })
  });

// ✅ Async version
const asyncServer = new McpServer({ name: "app", version: "1.0.0" })
  .withStateAsync(async () => {
    const config = await loadConfig();
    return { config, startTime: Date.now() };
  });

The code as is right now sets the state to an object but types it as unknown. If the user says a certain property is always set on the object but that hasn't happened yet then you still run into problems, with this pattern the state's type is derived from what the builder function returns.

@@ -0,0 +1,7 @@
---
"mcp-lite": major
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor? not sure if we wanna bump to v1

Suggested change
"mcp-lite": major
"mcp-lite": minor

private mountChild(
prefix: string,
suffix: string,
child: McpServer<TConfig>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thinking about the implication for child/grouped servers. all their Config types have to be compatible?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we make this a full fledged example? this is just gonna be a rogue typescript file in the examples dir

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh sneaky claude

Copy link
Contributor

@brettimus brettimus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bigger note:

the state example is a little confusing to me. the example suggests it is shared across requests from the examples. but i don't think it is, as this should be created afresh each time on each request, which calls createContext. example could use a little freshening up too as its just a rogue file that doesn't create a servable server (but maybe that's the point)

at any rate, given that this takes an optimistic typing approach, we need some way to seed the state on the request. i think the way you can optionally add authInfo when handling a request would be the correct place, as that's where you're actually creating new context for the jsonrpc req/res.

if you don't seed the request with a properly typed initialState, we should throw a type error.

on top of that at the very least probably wanna:

  • update readme in some capacity
  • update create-mcp-lite docs added to LLM agents, in case they reference ctx.env

@brettimus
Copy link
Contributor

What about using a withState() builder pattern? The api for the user would be something like:

// ✅ Chainable, clean inference
const server = new McpServer({ name: "app", version: "1.0.0" })
  .withState(() => ({
    count: 0,
    user: null as string | null
  }))
  .use((ctx, next) => {
    ctx.state.count += 1; // ✅ Typed
    await next();
  })
  .tool("getCount", {
    handler: (_, ctx) => ({
      content: [{ type: "text", text: String(ctx.state.count) }] // ✅ Typed
    })
  });

// ✅ Async version
const asyncServer = new McpServer({ name: "app", version: "1.0.0" })
  .withStateAsync(async () => {
    const config = await loadConfig();
    return { config, startTime: Date.now() };
  });

The code as is right now sets the state to an object but types it as unknown. If the user says a certain property is always set on the object but that hasn't happened yet then you still run into problems, with this pattern the state's type is derived from what the builder function returns.

i like the spirit of the builder pattern as it solves the issue of improperly seeded initial state (and the problem with optimistic typing more generally), but then with this example state initialization would get funky. i presume that you would want to initialize state with information pertaining to the incoming request (user info, ratelimit info, etc etc), so the builder would then need to accept some sort of initialization function or init data. then we / the end user needs to properly type the input to the builder and so on.

@laulauland
Copy link
Member Author

laulauland commented Oct 23, 2025

this is intentionally simple, following Hono's pattern: ctx.state is just a typed bag that starts empty on each request.

How it works:

  • each request gets a fresh ctx via createContext (context.ts:71)
  • state starts as an empty object: state: {} as TConfig["State"] (context.ts:83)
  • middleware populates properties as needed
  • TypeScript validates the types

no initialization needed - just declare the interface and set properties in middleware:

interface MyState {
  userId?: string;      // optional: might not be set
  startTime: number;    // required: auth middleware always sets this
}

const server = new McpServer<{ State: MyState }>({ ... });

server.use(async (ctx, next) => {
  ctx.state.startTime = Date.now(); // always set
  await next();
});

the example's requestCount line is misleading (will fix) - state doesn't persist across requests, it's fresh each time.

another thought: might need a better name than ctx.statectx.var or sth

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants