My first MCP server with Effect 4 went suspiciously smoothly
Contents
For the last few weeks I’ve been building swiss-mcp, a hyperpersonal MCP server with the tools I actually want my agents to have. It has 29 tools so far across four apps I use every day: Due for reminders and timers, OmniFocus for tasks, Slack, and YNAB.
By “the tools I actually want,” I mean tools that do precisely what I need, not thin wrappers around an API. A lot of MCP servers expose an API’s surface as dozens of granular tools, and the agent has to work out how to chain them together to get anything specific done. Slack is a good example: its API is enormous, but I only need a small slice of it. So instead of handing an agent search, thread, and reaction calls and hoping it combines them correctly, swiss-mcp has a slack_unanswered_mentions tool that does exactly that one job. It’s nicer for the agents, too, since a handful of purpose-built tools takes up far less context than a big collection they have to puzzle out.
Every line of it has been written by coding agents, with a lot of oversight and gardening from me. The codebase is deliberately constrained: the conventions are written down, and lint rules fail the build when they’re broken. My bet was that agents follow conventions much more reliably when breaking one fails the build. Nearly four weeks and 91 commits in, that bet is holding up.
I went in expecting friction. I’d used Effect 4 during its beta and kept running into small issues, so I was braced for more of the same. Now that Effect 4 is stable, I’ve been very impressed: I haven’t hit a problem that forced me to stop and restructure anything. Each new group of tools has slotted in next to the others. It’s gone smoothly enough that I keep waiting for the catch.
Effect ships its own MCP server
I expected to wrap the official MCP SDK in Effect. It turns out Effect 4 has an MCP server in effect/ai, built on the same Tool and Toolkit types it uses for LLM tool calling. The whole server is one Layer:
export const ServerLive = McpServer.toolkit(
Toolkit.merge(DueToolkit, OmniFocusToolkit, SlackToolkit, YnabToolkit),
).pipe(
Layer.provide(DueToolkitLive),
Layer.provide(OmniFocusToolkitLive),
Layer.provide(SlackToolkitLive),
Layer.provide(YnabToolkitLive),
Layer.provide(Shortcuts.layer),
Layer.provide(OmniFocus.layer),
Layer.provide(Focus.layer),
Layer.provide(SlackApi.layer),
Layer.provideMerge(
McpServer.layerStdio({
name: pkg.name,
version: pkg.version,
protocols: [McpProtocol.v2025_11_25, McpProtocol.v2025_06_18],
}),
),
Layer.provide(BunServices.layer),
)
Each group of tools is a toolkit with a matching Live layer, and the services they depend on (a typed wrapper around macOS’s shortcuts CLI, a bridge to OmniFocus, a Slack client) are layers too. So adding OmniFocus only took a toolkit, the service that talks to OmniFocus, and two more Layer.provide lines. The same definition serves two MCP protocol versions.
A tool is a schema and a handler
Here’s the YNAB tool’s definition:
Tool.make('ynab_daily_pace', {
description:
"Daily spending pace: YNAB's Ready to Assign divided by the days " +
'until the next pacing date. ...',
parameters: Tool.EmptyParams,
success: DailyPace,
failure: ToolFailure,
failureMode: 'return',
})
.annotate(Tool.Strict, true)
.annotate(Tool.Readonly, true)
.annotate(Tool.Destructive, false)
.annotate(Tool.OpenWorld, true)
The parameters, the result, and the failure are all Effect Schemas. One definition validates the input, encodes the output, and produces the JSON Schema that the MCP client shows the model. With failureMode: 'return', a failure goes back to the model as a structured result it can read, not a protocol error. Tagged errors like ShortcutTimedOut and YnabNotConfigured become messages written for the model, such as telling it not to retry blindly.
The handler is an Effect. Here’s the one that starts a Due timer:
export const DueToolkitLive = DueToolkit.toLayer(
Effect.gen(function* () {
const shortcuts = yield* Shortcuts
return {
due_start_timer: (p) =>
shortcuts
.run(contracts.startTimer, { op: 'timers.start', ...p })
.pipe(
Effect.map((timers) => ({ timers })),
toFailure,
),
// ...the other six Due tools
}
}),
)
The layer asks for the Shortcuts service once, and every handler shares it. p arrives already decoded and typed from the tool’s parameters Schema. toFailure turns a tagged error into the ToolFailure shape the definition promised.
Configuration goes through Config. The YNAB and Slack tokens are Config.Redacted values, which print as <redacted> and are only unwrapped where a request is made.
Constraining the agents
The repo has a short AGENTS.md. It tells agents to read the agent guide that the effect package ships at node_modules/effect/AGENTS.md before they write Effect code, and it has three rules. Named Schemas live in a sibling *-schema.ts file. Error classes live in a sibling *-errors.ts file. A function that only returns Effect.gen is written with Effect.fn instead. Two small oxlint JS plugins enforce all three, and the plugins have their own tests:
{
"jsPlugins": ["./lint/location-plugin.js", "./lint/effect-plugin.js"],
"rules": {
"location/schema-location": "error",
"location/error-location": "error",
"effect/no-gen-wrapper": "error"
},
"options": { "typeAware": true }
}
I added the location rules after the first Slack tools landed, once I could see where the code was starting to drift. Later I moved the repo from Biome to oxlint and oxfmt and turned on type-aware rules. The Effect.fn rule came last, straight out of Effect’s own guide. That’s most of what I mean by gardening. I notice a pattern I don’t like, then turn it into a rule instead of another line in a prompt. I’ve written before that constraints build capacity in a way goals don’t, because you shape the conditions until the behavior you want becomes the path of least resistance. It turns out that’s just as true for agents.
Effect’s types help here more than I expected. If an agent forgets to provide a service, the program doesn’t type check. Each tool’s possible failures are spelled out in its signature, which is exactly the gap in plain TypeScript I wrote about a while back. The tests start the real server over stdio and talk to fake Slack and YNAB APIs and a fake osascript for OmniFocus, so a change has to work end to end, not just compile.
Where it rubbed
Almost all of the friction came from MCP details, not Effect:
- When the server failed to start,
runMain’s error report went to stdout and corrupted the JSON-RPC stream. The fix logs the failure to stderr and turns offrunMain’s own report. - MCP’s
structuredContenthas to be a JSON object, so tools that return lists wrap them, as in{ timers }above. - The JSON Schema that Effect generates for a tool leaves out
pattern, so the expected date format has to go in the description too. - The two protocol versions report invalid arguments differently. The 2025-06-18 version returns a JSON-RPC
-32602error, and 2025-11-25 returns a tool result withisError. The test checks only what both share: the message, and that the shortcut never ran.
The biggest change of direction was how Due dates work. I switched from ISO instants to local wall-clock time, because that’s how Due stores them. That was about Due, not about how the code was put together.
Conclusion
I’ll keep adding tool groups as I need them. If you’re building an MCP server in TypeScript and you already like Effect, try effect/ai before you reach for the SDK.
The bigger lesson for me isn’t about Effect, though. I didn’t get agents to write consistent code by writing better prompts. I got there by shaping the codebase so the right thing was the easy thing and the wrong thing failed the build.
A prompt asks an agent to follow a convention. A constraint makes it the only way through.