@omelhorsite/mcp
v0.2.0
Published
Model Context Protocol server exposing the omelhorsite SDK to agents, in the code-mode shape: two tools, and the SDK's own type declarations as the interface.
Readme
@omelhorsite/mcp
An MCP server for the omelhorsite API, built in the code-mode shape: instead
of one tool per endpoint, it exposes two, and the model writes TypeScript
against @omelhorsite/sdk.
search(query) find the part of the API you need, as real type declarations
execute(code) run TypeScript against the SDK and get the returned value backThe bet is Cloudflare's: a model writes better TypeScript than it writes tool calls, because it has read an enormous amount of the former and very little of the latter. Two things follow.
The types are the interface. There is no hand-written schema anywhere in
this package: tsc emits .d.ts from packages/core, and search serves
those declarations with their JSDoc intact. The JSDoc on this SDK documents rate
limits, quota units, error shapes and the places the backend surprises you, so
the model reads the same warnings a developer would.
The work happens in one round trip. "Find every file over 100 MB in my
storage and total them up" is a loop. As seventy tools it is twenty turns of
list, read, filter, call again. As one execute call it is nine lines that run
once.
Read this before you install it
THIS IS NOT A SANDBOX.
executeruns the model's code in abunsubprocess on your machine, as you, with your credential. It is not a Cloudflare isolate. There is no strong security boundary against hostile code.The subprocess bounds ACCIDENTS - a runaway loop, a program that never returns, output that would fill memory. It bounds nothing else. Code passed to
executecan read and write your files, open sockets,await import("node:fs"), and read the token out of its own environment.The trust model is exactly the trust model of giving an agent shell access. If you would not let this agent run
bash, do not give it this server.
What is actually done about it, so the claim above stays precise:
| Measure | What it buys | What it does not |
| --- | --- | --- |
| Runs in a subprocess | a crash or a hang cannot take the server down | it is the same user, on the same machine |
| Wall-clock deadline, then SIGKILL | no run wedges the client forever | a program can do damage in a second |
| stdout/stderr piped, never inherited | a console.log cannot corrupt the JSON-RPC stream | nothing |
| Minimal child environment | the server's other variables never cross | OMS_TOKEN does cross, by necessity |
| The token never enters the program file | it is not in a world-readable temp file, or in a stack trace | the code can still read process.env |
| Credential-shaped keys redacted from results | return oms does not put your token in a transcript | a program that wants to leak it, can |
| Imports restricted to the SDK | a mistyped import fails with an explanation | await import(...) is not stopped |
Where a real sandbox goes
One function: runCode in src/runner/run.ts. Everything above it - the
generated program text, the result protocol in src/runner/protocol.ts, both
tools - is independent of how the code is evaluated.
Two replacements would give a real boundary:
- the Dynamic Worker Loader (
env.LOADER.get(id, () => ({ modules }))), which is what Cloudflare's own code mode uses: a V8 isolate per snippet, no filesystem, no ambient network, and outbound calls only through a binding the host controls; - the Sandbox SDK
(
getSandbox(env.Sandbox, id).exec(...)) when a full container is wanted instead.
Either one additionally makes the credential unreachable by the code that uses
it: the snippet gets a request function, not the token. That is the property a
subprocess fundamentally cannot provide, and it is why this file says what it
says.
Configuring it in Claude Code
The server takes its credential from the environment. An MCP server has no TTY - no prompt, no browser, no follow-up question - so there is no interactive login and there will not be one.
.mcp.json
Project-scoped, checked in next to the repo (without the token):
{
"mcpServers": {
"omelhorsite": {
"command": "bun",
"args": ["run", "/absolute/path/to/omelhorsite/apps/cli/packages/mcp/src/server.ts"],
"env": {
"OMS_TOKEN": "${OMS_TOKEN}"
}
}
}
}From the command line
claude mcp add omelhorsite \
--env OMS_TOKEN="$OMS_TOKEN" \
-- bun run /absolute/path/to/omelhorsite/apps/cli/packages/mcp/src/server.tsCheck it came up with /mcp inside Claude Code. The server writes its
diagnostics to stderr; a client that shows MCP server logs will report
oms-mcp: ready: 319 declarations, base https://backend.omelhorsite.pt.
Getting a token
Either credential the API accepts works, because they are indistinguishable on the wire:
- an OAuth access token from the device flow, which is what
oms auth loginin@omelhorsite/cliproduces. Ask for the narrowest scopes the agent needs:openid, then any ofstorage:read,storage:write,tools:read,tools:write,tickets:write. No scope grants administrative access. - a legacy session token, the opaque UUID the web app holds. It carries full account authority and never expires, which is exactly why it is the worse choice for an agent.
Leaving OMS_TOKEN unset is supported and the server says so on stderr. Short
links, notepads, chests and IP lookup still work anonymously, at a smaller daily
quota; anything account-shaped answers 401.
Environment
| Variable | Default | Meaning |
| --- | --- | --- |
| OMS_TOKEN | none | The credential. Absent means anonymous. |
| OMS_BASE_URL | https://backend.omelhorsite.pt | API root. Point it at a local backend. |
| OMS_MCP_TIMEOUT_MS | 60000 | Deadline for one execute, and its ceiling. Capped at 600000. |
| OMS_MCP_MAX_OUTPUT_CHARS | 20000 | Per-section cap on what execute reports. |
| OMS_MCP_BUN | the running bun | Override the binary the runner re-invokes. |
The two tools
search(query, limit?)
Returns declarations, not prose. Query it however is natural:
- a question:
"how do I shorten a URL" - a dotted path:
"oms.storage.upload" - an exact type name:
"CreateShortLinkInput" - empty: the map of the whole client
Ranking is weighted token overlap with two corrections that matter on real
questions. Rarity, because url and id and create are in half the SDK
and carry almost no signal while shorten is in three declarations.
Coverage, because an entry matching every word of the query is answering it
and one matching a single common word is a coincidence. Without either,
"how do I shorten a URL" returns MultipartPartUrl.
execute(code, timeoutMs?)
Runs a snippet. oms is constructed and authenticated; every runtime export of
the SDK is already in scope. await and return both work at the top level,
because the snippet is wrapped in an async function.
The answer separates three things: the returned value, anything printed to
stdout/stderr, and notes about what was truncated or removed. A
thrown error comes back with the SDK's own detail - status, code, method,
url, fieldErrors, retryAfterMs - which is what a model needs to decide
between retrying, re-scoping and giving up.
Values that JSON cannot carry are tagged rather than lost: a Blob reports its
size, a Uint8Array its length, a cycle is marked, a long listing is cut and
the cut is stated.
Code the model writes
Real signatures from the current SDK. Each block is exactly what would go in the
code argument.
Look something up.
const google = await oms.ipLookup.get("8.8.8.8");
return { asn: google.asn, org: google.organization, country: google.country };Shorten a URL and read its statistics. Note the JSDoc that search returns
for create: ten creations per hour per IP, for everyone, signed in or not.
const link = await oms.shortLinks.create({ url: "https://example.com", endpoint: "demo" });
const stats = await oms.shortLinks.stats(link.id);
return { url: oms.shortLinks.shortUrl(link), clicks: stats.total_clicks };Walk a folder tree and find what is big. This is the case the whole design exists for: a loop, a pager and a filter, in one call instead of twenty turns.
const roots = await oms.storage.roots();
if (roots.home === null) return "no home directory";
const big: { name: string; mb: number }[] = [];
const queue: string[] = [roots.home];
while (queue.length > 0 && big.length < 20) {
const parentId = queue.shift()!;
const children = await collect(await oms.storage.list({ parentId, pageSize: 500 }), 5000);
for (const node of children) {
if (node.kind === "directory") queue.push(node.id);
else if (node.size > 100_000_000) big.push({ name: node.name, mb: Math.round(node.size / 1e6) });
}
}
return big.sort((a, b) => b.mb - a.mb);Run a metered tool and wait for it. Check the quota first; the unit differs per tool (seconds of media for the audio and video tools, edits for jumpstyle).
const quota = await oms.tools.transcription.quota();
if (!quota.unlimited && (quota.remaining_seconds ?? 0) < 60) {
return { skipped: "not enough daily quota left", quota };
}
const audio = await oms.storage.download("<fs node id>");
const done = await oms.tools.transcription.run({
audio: file(audio.data, audio.filename ?? "audio.m4a"),
language: "pt",
});
return { status: done.status, text: done.text };Local helpers, no network and no credential.
return {
passphrase: generatePassphrase({ words: 5 }),
strength: passwordStrength(generatePassword({ length: 20 })),
};Regenerating the types
Run this after any change to packages/core/src:
bun run --filter '@omelhorsite/mcp' build:typesscripts/generate-types.ts runs tsc over the SDK, then parses the emitted
declarations with the TypeScript compiler API and writes three things into
generated/:
| File | What it is |
| --- | --- |
| sdk/**/*.d.ts | raw tsc output, one file per SDK module |
| catalog.json | the search index: declarations, summaries, members, runtime exports |
| sdk.d.ts | every declaration in one file, for a human to read |
generated/ is committed on purpose. An MCP client launches this server without
running a build first, and a server that needs one fails on the first call, in a
place nobody is watching.
The cost of committing it is that it can go stale, so the catalog records the byte size of every SDK source and the server compares them at start-up. A mismatch prints a warning naming the changed files. It is a size check, not a hash: it catches "somebody edited the SDK and forgot to regenerate", which is the failure that happens, and misses an edit that preserves the length, which is not.
Layout
scripts/
generate-types.ts tsc -> .d.ts -> catalog.json (build time)
tsconfig.declarations.json
src/
server.ts the bin: wires the two tools onto stdio
config.ts the only module that reads process.env
paths.ts everything located from import.meta.url
catalog.ts the search index and its scoring (no I/O)
catalogFile.ts loading it, and noticing when it is stale
host.d.ts the host globals this package adds to types/host.d.ts
tools/
search.ts ranking, budgeting and rendering declarations
execute.ts the tool description and the answer format
runner/
run.ts spawn, deadline, capture <- the sandbox seam
program.ts snippet -> program file
harness.ts the child side: builds the client, reports the result
encode.ts values JSON cannot carry
protocol.ts the one shape both sides agree on
generated/ committed build output (see above)
test/src/host.d.ts merges Bun.spawn, BunFile.delete and process.execPath into
the workspace's shared types/host.d.ts rather than editing it, because that
file belongs to the workspace and this package's needs are its own. Both go away
when @types/bun is approved as a dependency.
Limitations
These are known and unfixed, not oversights.
No sandbox. Said at length above. It is the only one that changes what you should be willing to point this at.
Snippets are not typechecked. bun strips TypeScript without checking it,
so a type error in the model's code surfaces as a runtime failure - or, worse,
does not surface at all when the annotation was wrong but the value happened to
work. Running tsc over the snippet against generated/sdk before spawning
would catch this, at roughly a second per call, and is a clean addition to
runner/program.ts.
No streaming and no progress. A tool result arrives whole, at the end. A
five-minute transcription is silent for five minutes and then answers, so budget
timeoutMs for it. MCP progress notifications would fix this and the runner
would need to forward the SDK's onProgress callbacks out of the subprocess.
One process per call. Roughly 40 ms of bun start-up per execute, and no
state survives between calls: a variable set in one snippet does not exist in
the next. Ids, tokens and handles must be returned and passed back in. This is
deliberate - a persistent interpreter is a much larger surface and a much
stranger failure mode - but it is a real cost on a multi-step task, which is an
argument for writing one bigger snippet.
Search is lexical. Token overlap with IDF and coverage weighting, no embeddings. It is good on nouns from the API and weaker on synonyms the SDK does not use: "transcribe" does not stem to "transcription", so that query ranks the captions tool first. The fallback is to search the exact name.
Declarations carry their private members. tsc emits private readonly
children; lines because they matter for structural typing, and they are served
as-is. Trimming them would mean the text no longer matches what the compiler
produced, which is a worse trade than the tokens they cost.
The catalog staleness check is a size comparison. See above.
Paths assume the package runs from source. src/paths.ts locates
generated/ and packages/core relative to itself. Bundling src/ into a
dist/ moves it, and those constants have to move too.
