@ifit/charlie-mcp-server
v1.0.0
Published
Expose Charlie ITool[] as an MCP server: transport-agnostic factory plus stdio, Web-standard HTTP, and Node req/res adapters
Maintainers
Keywords
Readme
@ifit/charlie-mcp-server
Expose Charlie ITool[] (the same tools you pass to getResponse({ tools }))
as a real MCP server. This is the reverse of @ifit/charlie-mcp, which lets
Charlie call tools from external MCP servers — this package lets an external
MCP client call Charlie's tools.
Three classes, each constructed with an options object (tools, name,
version, plus transport-specific options) — no factory functions to thread
through. Constructing one is enough to hand it to a DI container (Nest
FactoryProvider, InversifyJS, etc.) as a plain injectable instance.
Why the low-level Server, not McpServer
ITool.jsonSchema is already JSON Schema. The SDK's high-level
McpServer.registerTool() wants a Standard Schema / Zod input schema, and
not every ITool has one (MCP-adapted tools only have JSON Schema).
CharlieMcpServer registers tools/list / tools/call handlers on the
low-level Server and hands JSON Schema straight through, since that is
exactly what the MCP wire format wants for Tool.inputSchema. Server is
marked @deprecated in favor of McpServer in the SDK, but that note is
about the ergonomics of hand-authoring Zod tools — wrapping an already-
external tool catalog is the "advanced use case" the SDK docs point at
Server for.
Quickstart
import { CharlieMcpStdioServer } from "@ifit/charlie-mcp-server";
import { CalculatorTool } from "./tools/calculator.tool";
// stdio — for Claude Desktop, CLI agents, or anything that spawns you as a child process
new CharlieMcpStdioServer({
tools: [new CalculatorTool()],
name: "my-charlie-tools",
version: "1.0.0",
}).serve();HTTP, and mounting in Express or NestJS
CharlieMcpHttpHandler wraps a Web-standard handler
(fetch(request) => Promise<Response>) — framework-agnostic by construction
— plus a handle(req, res) method that bridges Node's classic request/
response shape, so an instance drops straight into an Express route:
import express from "express";
import { CharlieMcpHttpHandler } from "@ifit/charlie-mcp-server";
const mcpHandler = new CharlieMcpHttpHandler({
tools,
name: "my-charlie-tools",
version: "1.0.0",
});
const app = express();
app.use(express.json());
app.all("/mcp", (req, res) => mcpHandler.handle(req, res));The same instance and method work verbatim in a NestJS controller on the
(default) Express platform, since Nest hands route handlers the underlying
Node req/res there too. Construct it once, e.g. via a FactoryProvider,
and inject it:
@Injectable()
export class McpController {
constructor(private readonly mcpHandler: CharlieMcpHttpHandler) {}
@All("mcp")
mcp(@Req() req: Request, @Res() res: Response) {
return this.mcpHandler.handle(req, res, {
meta: { user: req.user },
});
}
}A Fastify-platform Nest app would need Fastify's own request/response bridge instead — not something this package builds.
If Express (or similar) body-parsing middleware already populated req.body,
handle forwards it as parsedBody automatically, since the request stream
has already been consumed by that middleware and cannot be re-read.
CharlieMcpHttpHandler also exposes fetch(request, options?) directly, for
Web-standard runtimes that don't need the Node bridge, and close() to tear
down the underlying handler.
Tool-call semantics
- A tool's return string becomes
{ content: [{ type: "text", text }] }. - A thrown error (including a
BaseToolZod validation failure) becomes{ content: [...], isError: true }— the same tolerant behaviorToolExecutoruses on the Charlie side, so a bad call doesn't kill the session. ITool.returnDirectis a Charlie chat-loop concept (skip the rest of the loop and hand the result straight to the caller) and has no meaning to an MCP client — it is ignored here.- Each
tools/callgets its ownChatAgentContext(freshrunId, emptymessages). There is no chat run behind an MCP call, soToolStart/ToolEnd— emitted by Charlie'sToolExecutoraround a chat-loop tool call — never fire here. If a tool handler emitsToolProgress/Logitself viacontext.eventProducer, that is still observable in-process (core's sharedeventProducersingleton by default, or pass your own).ToolProgressis also forwarded as MCPnotifications/progresson that sametools/callwhen the client sent aprogressToken— SDK clients do this automatically if they passonprogress. HTTP can upgrade that POST to SSE so the notifications arrive before the result.Logis not forwarded.subscriptions/listenis unused. context.metais the merge of the client's per-call_meta, then constructormeta, then hostmetafromhandle/fetch(orrunWithMeta). Later keys win, so clients cannot overwrite constructor or host values. The authorized user must come from the HTTP layer — do not trust_metafor auth. InitializeclientInfois still only the client app'sname/version, not end-user identity.
