This tutorial starts from a generated Fentaris project and turns it into a proxy for two upstream MCP servers. The app code keeps runtime endpoint settings in fentaris.json and declares upstream servers with app.mcp(...).
Quick Start
Use the optional agent skills when you want an agent to ask setup questions, choose upstream MCP servers, and validate the project after edits:
npx skills add Fentaris/fentaris-skills --skill '*'
Then restart or reload your agent and ask it to use the setup skill:
Use $fentaris-project-setup to help me create a Fentaris proxy for my team.
If you prefer to build the tutorial manually, create a project with the CLI:
npm i -g @fentaris/cli
fentaris init tutorial-proxy
cd tutorial-proxy
fentaris init writes fentaris.json. Keep endpoint settings there instead of hardcoding them in src/index.ts:
{
"name": "tutorial-proxy",
"packageManager": "pnpm",
"entrypoint": "src/index.ts",
"port": 4000,
"host": "127.0.0.1",
"path": "/mcp",
"authDir": ".fentaris"
}
When app.start() runs without explicit options, @fentaris/core searches upward from the current working directory and reads the nearest fentaris.json. With the config above, the local endpoint is http://localhost:4000/mcp.
Change port or path in fentaris.json when the local endpoint needs to move. Keep src/index.ts focused on proxy behavior.
Step 2: Create the App Boundary
Open src/index.ts and start with an empty Fentaris app:
import { Policy, fentaris, stdio, streamableHttp } from "@fentaris/core";
const app = fentaris({
policy: Policy.allowAll(),
});
fentaris() creates the proxy application boundary. Project discovery, host, port, and path come from fentaris.json, while upstream MCP servers are attached with app.mcp(...). Policy.allowAll() keeps this local tutorial focused on routing; replace it with an allow-list policy before sharing the endpoint.
Step 3: Attach the Filesystem Server
Create a local directory that the filesystem MCP server can expose:
mkdir demo-files
echo "hello from Fentaris" > demo-files/readme.txt
Then register the upstream MCP server:
app.mcp("filesystem", {
displayName: "Filesystem",
transport: stdio({
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "./demo-files"],
}),
});
app.mcp("filesystem", ...) gives the upstream server a stable Fentaris name. Fentaris uses that name when routing tool calls, applying middleware, logging events, and exposing proxied tool names such as filesystem__read_file.
Step 4: Attach a Remote Server
Add the public MCP specification server as a second upstream:
app.mcp("specification", {
displayName: "MCP Specification",
transport: streamableHttp({
url: "https://mcp.specification.website/mcp",
}),
});
The specification server requires network access. The filesystem server remains local, so you can still start the proxy and test local routing if the remote server is unavailable.
Step 5: Add Global Middleware
Middleware runs after Fentaris builds request context and before the matching upstream MCP server receives the call:
app.use(async (ctx, next) => {
ctx.log.info("tool-call", {
server: ctx.server?.name,
tool: ctx.tool?.name,
});
if (ctx.server?.name === "filesystem" && ctx.tool?.name === "delete_file") {
return ctx.deny("Filesystem delete is disabled in this environment.");
}
return next();
});
Use middleware for runtime checks that depend on request data, tool arguments, tenant state, or an approval workflow. For static allow-list access, use policy rules.
Step 6: Add a Server Hook
Server-scoped handles keep observability close to the upstream MCP server they describe:
app.mcp("specification").on("tool:success", async ({ ctx, durationMs }) => {
ctx.log.info("specification.tool.success", {
tool: ctx.tool?.name,
durationMs,
});
});
This hook only observes successful tool calls for the specification upstream MCP server. It does not affect filesystem calls and does not replace middleware or policy decisions.
Step 7: Start the Proxy
Finish src/index.ts with app.start():
The complete entrypoint should look like this:
import { Policy, fentaris, stdio, streamableHttp } from "@fentaris/core";
const app = fentaris({
policy: Policy.allowAll(),
});
app.mcp("filesystem", {
displayName: "Filesystem",
transport: stdio({
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "./demo-files"],
}),
});
app.mcp("specification", {
displayName: "MCP Specification",
transport: streamableHttp({
url: "https://mcp.specification.website/mcp",
}),
});
app.use(async (ctx, next) => {
ctx.log.info("tool-call", {
server: ctx.server?.name,
tool: ctx.tool?.name,
});
if (ctx.server?.name === "filesystem" && ctx.tool?.name === "delete_file") {
return ctx.deny("Filesystem delete is disabled in this environment.");
}
return next();
});
app.mcp("specification").on("tool:success", async ({ ctx, durationMs }) => {
ctx.log.info("specification.tool.success", {
tool: ctx.tool?.name,
durationMs,
});
});
await app.start();
Run the proxy:
Step 8: Connect a Client
Point your MCP client to the endpoint from fentaris.json:
http://localhost:4000/mcp
Fentaris exposes one MCP endpoint and routes each request to the matching upstream MCP server. The client talks to Fentaris; Fentaris starts or connects to upstream MCP servers, applies policy and middleware, forwards allowed calls, and records hooks and logs.
Validate the Project
Run static project checks before sharing the endpoint:
When the proxy is running, verify the runtime endpoint:
fentaris doctor --runtime
The tutorial is suitable for local development. Add authentication and allow-list policies before exposing a Fentaris endpoint outside your machine.
What You Built
You now have:
- one Fentaris endpoint configured by
fentaris.json
- two upstream MCP servers declared with
app.mcp(...)
- global middleware for logging and request-time denial
- a server-scoped hook for remote upstream observability