How an app is published here, from your server to your wallet. Ten minutes to read, and everything the form will ask you.
Any running MCP server can be an app on Otis. You give the store its address and a listing, a person reviews it, and from then on agents call it through their Otis key and you are paid in CREDIT for every call. No code is uploaded and your server never handles money. This is everything you need to know to publish one.
An app is a set of tools an agent can call. On Otis those tools live on your server, and the store sits between your server and the agents: it reads your tools once, shows them on the shelf, meters every call, charges the caller and pays you. An agent never sees your address, and you never see the agent’s key.
You keep running the server. The store connects to it for each call, asks one question and closes the connection. If your server is down, the agent is told the app did not answer and nobody is charged.
https://mcp.example.com/mcp. A private address, a plain http one, or one that redirects, is refused before anything is asked.Three calls are all it needs to answer: initialize, tools/list and tools/call. The example at the end is the whole of it in thirty lines.
A description is all an agent has to go on. It reads it, decides whether to call the tool, and writes the input from the schema. So write each tool as if for a careful stranger.
token_facts, send_invoice. One alphabet to a word.maxLength, minimum, maximum, an enum of the values you take), required for what is required, and additionalProperties: false.data: or javascript: links. A reviewer cannot see them and an agent still reads them.These are the checks run the moment you press “Read my server”, and they are all shown with what failed and why. Fix what they name and press it again; nothing is submitted until you say so.
When you create the app you are shown a secret once. Every call the store makes to your server carries it in the x-otis-secret header. Put it in your server and refuse any request without it, so somebody who finds your address cannot run up your costs. You can make a new one from the app’s page at any time.
The store tells your server one other thing, in x-otis-account: a tag for the account that is calling. It is the same tag every time that account calls your app and means nothing anywhere else, so you can count, rate-limit or remember per account without ever knowing who they are. Your server is never told a key, a wallet, an email or a name.
What an agent passes to your tool arrives as it was written. Treat it as text from a stranger.
One price for the app, in CREDIT, charged for every call to any of its tools. Zero is free. You are paid most of each call’s price; the store keeps the rest for the review, the door, the metering and the payout.
Price it from what a call costs you. If every answer is a paid call to a data service that costs you two cents, 0.05 CREDIT covers it with room. A lookup that costs you nothing can be 0.01, or free. One CREDIT is a dollar of AI to the person paying, so an agent with a dollar of usage can make twenty calls at 0.05.
Since the price is per app, price for the average tool, not the dearest. If one tool costs you ten times the others, give it its own app.
Priced calls are paid from usage the caller has topped up. The free dollar a day does not pay for them, so a caller with no topped-up usage gets a refusal that tells them to top up, and you are not called.
Submit, and two things happen. The checks above run again against your server, and what they read is frozen: the tools, their descriptions and their schemas, as a version. Then a person reads every tool. They approve it, or send it back with a note saying what to change.
What was approved is what agents get. If you change a tool on your server, the change does not reach agents until you submit a new version and it is approved; the old version keeps serving until then.
The store can take any app off the shelf. A paused app answers agents with a refusal and charges nothing, and comes back when it is resumed.
Every call is metered as it happens. Your part of the price is held for you from the moment the call is charged, and the Earnings tab shows every call and what it earned. A payout is a CREDIT transfer to the wallet on your developer profile, on Robinhood Chain, and every one is a transaction you can look up. Change the wallet from your profile at any time; payouts from then on go there.
CREDIT is Orbio’s token, and a dollar of AI when it is burnt. So what you earn can pay for your own app’s AI through Orbio, be sold on Orbio’s order book, or be held.
The whole of a working app, in Node, with one tool. It checks the secret, lists the tool, answers a call. Run it on a host with a public https address, and give that address to the store.
import { createServer } from 'node:http';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
const SECRET = process.env.OTIS_APP_SECRET; // shown once when the app is created
const TOOLS = [{
name: 'word_count',
description: 'Counts the words in a text. Pass the text.',
inputSchema: { type: 'object', properties: { text: { type: 'string', maxLength: 20000 } }, required: ['text'], additionalProperties: false },
}];
createServer(async (req, res) => {
if (req.method !== 'POST') { res.writeHead(405); res.end(); return; }
if (SECRET && req.headers['x-otis-secret'] !== SECRET) { res.writeHead(401); res.end(); return; }
const chunks = []; for await (const c of req) chunks.push(c);
const body = JSON.parse(Buffer.concat(chunks).toString() || 'null');
const server = new Server({ name: 'word-count', version: '1.0.0' }, { capabilities: { tools: {} } });
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
const n = String(params.arguments?.text ?? '').split(/\s+/).filter(Boolean).length;
return { content: [{ type: 'text', text: `${n} words.` }] };
});
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, body);
}).listen(4200);Then: sign in and open Publish an app in your dashboard, paste the address, press “Read my server”, and submit.