Creating modules
What a module is, what your repository needs, and how to get it into the portal.
A module is a Cloudflare Worker that speaks MCP. You write it in TypeScript, keep it in a GitHub repository, and Odel builds and runs it — there is no in-browser editor, and nothing to host yourself.
What your repository needs
Three things, and the defaults assume this exact shape:
| Path | Why |
|---|---|
package.json | With a build script. The build runs it and stops if it fails. |
src/index.ts | Your module. This is the file that actually gets bundled and deployed. |
wrangler.jsonc | Optional locally, for wrangler dev. Odel doesn't read it. |
The module can live in a subdirectory of a larger repository — set Root directory in advanced build settings and everything above is relative to that.
The smallest module that works
import { createOdelServer, WebStandardStreamableHTTPServerTransport } from '@odel/module-sdk/server';
import { z } from 'zod';
function buildServer() {
const server = createOdelServer({ name: 'my-module', version: '1.0.0' });
server.registerTool(
'add',
{
description: 'Add two numbers together',
inputSchema: { a: z.number(), b: z.number() }
},
async ({ a, b }) => {
const result = { success: true as const, result: a + b };
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
}
);
return server;
}
export default {
async fetch(request: Request): Promise<Response> {
const server = buildServer();
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
return transport.handleRequest(request);
}
};With a package.json alongside it:
{
"type": "module",
"scripts": {
"build": "tsc --noEmit",
"dev": "wrangler dev"
},
"dependencies": {
"@odel/module-sdk": "^2.0.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250110.0",
"typescript": "^5.9.2",
"wrangler": "^3.102.0"
}
}That's a complete module. Everything on top of it — reading who is calling, using a user's API key, returning typed results — is covered in the Module SDK.
Two runnable examples
The SDK repository ships calculator-basic,
which is roughly the above, and foobar,
which exercises the context envelope, declared secrets, typed errors, and output schemas.
Why a fresh server per request
An isolate handles one call and may be gone before the next, so building the server and transport
inside fetch is what behaves identically on the first request and the ten-thousandth. Passing
sessionIdGenerator: undefined makes the transport stateless: it accepts a bare tools/call
without an initialize handshake first, which is how Odel's proxy invokes modules.
Creating the project
New projects start in the developer portal. New Project opens a wizard offering three routes:
| Method | What it's for |
|---|---|
| Connect GitHub Repository | Your own TypeScript module — this page |
| External MCP Server | A server that already exists and runs elsewhere, shared through a gateway |
| VibeModule Builder | Building visually, with AI. Shown as coming soon, not yet available. |
Connecting the repository
Odel needs its GitHub App installed on the account or organization that owns the repository. The wizard walks you through it if it isn't, and after installing you'll need to reopen the wizard before the repository list appears.
You then pick a repository, and the wizard reads its default branch.
Naming it
The remaining details — display name, tagline, category, icon — are optional and editable later. The display name is pre-filled from the repository name.
One field is worth pausing on. The project identifier becomes part of your module's permanent address:
@your-org-slug/your-module-nameIt is suggested at random — golden-dragon, crystal-river — with a button to reroll, and most
people should type over it. The name must be 1–50 characters of lowercase letters, numbers and
hyphens, can't start or end with a hyphen, and can't contain two hyphens in a row.
The @your-org-slug half comes from the organization that's active when you create the project,
so check the selector if you belong to more than one. It decides who owns the module and whose
vault its secrets come from.
Nothing is live yet
Creating the project gives you a draft. Nothing is deployed, nothing is in the marketplace, and no URL resolves. The project dashboard has six tabs — Overview, Store Page, Deployments, Statistics, Logs, Inspector — and you move through them in roughly that order.
What happens when you deploy
The full pipeline is on Deploying, but two parts of it change how you lay out your code:
Your build script runs, but its output isn't what ships. After pnpm build succeeds, Odel
bundles your TypeScript source with esbuild and deploys that. The entry point setting is how it
finds the source: dist/index.js is read as src/index.ts. So the build script's real job is to
fail loudly on a type error — tsc --noEmit is a perfectly good one — and your source has to sit
where the entry point implies.
Your lockfile is discarded. It's deleted before installing so that lockfiles from other
environments don't break the build, which means dependency versions resolve fresh from your
package.json ranges. If an exact version matters, pin it there rather than relying on the lock.
No local path dependencies
A file: dependency in package.json fails the build with an explicit error — the build
container only has your one repository. workspace:* won't resolve either. Publish the dependency
to npm, or vendor the code into the repo.