To turn a local stdio MCP server into a connector your teammates can use, you re-express its tools, resources and prompts with defineMcp, add "connector": true to denkops.json, and deploy. You get a public HTTPS URL speaking streamable HTTP at /mcp, plus a hosted OAuth flow. Be clear-eyed about the one manual part: there is no automatic stdio-to-HTTP wrapper. You port your tool definitions by hand. It is usually an hour of work, and the payoff is that the server stops living only on your laptop.
Why can't I just point a client at my stdio server?
Because a stdio server has no address. The assistant starts your process, talks to it over stdin and stdout, and kills it. That is perfect for one developer and impossible for anyone else, no URL to paste, no login, no always-on process for the next call. A connector is the opposite: it runs in a process that stays up, answers at a stable URL, and lets one server serve every client. See what is an MCP connector for the full contrast.
Your tool *logic* carries over unchanged. What changes is how the tools are declared and how the transport is served. That declaration is what you port.
How do I port my tools into defineMcp?
Your stdio server has a list of tools, each with a name, a description, an input schema and a handler. defineMcp wants exactly the same four things, arranged as a config object you export as the module default. Resources and prompts map across the same way.
import { defineMcp } from "@denkopsai/sdk/mcp";
import { z } from "zod";
import { lookupContact, recentNotes } from "./crm";
export default defineMcp({
name: "crm-tools",
version: "1.0.0",
tools: {
find_contact: {
description: "Find a contact by email address.",
input: z.object({ email: z.string().email() }),
handler: async ({ email }) => lookupContact(email),
},
},
resources: {
playbook: {
uri: "file://playbook.md",
description: "Team sales playbook.",
mimeType: "text/markdown",
read: () => ({ text: recentNotes(), mimeType: "text/markdown" }),
},
},
prompts: {
intro_email: {
description: "Draft an intro email to a contact.",
input: z.object({ name: z.string() }),
build: ({ name }) => [
{ role: "user", content: `Write a short intro email to ${name}.` },
],
},
},
});That is the whole surface. Tools take { description, input, handler }, where input is a Zod schema (recommended) or a plain JSON Schema. Resources take { uri, description, mimeType, read }. Prompts take { description, input, build }, and build returns an array of { role, content } messages. If you already build tools with Zod, mcp tools with zod and mcp resources and prompts go deeper on each.
The porting itself is mechanical: copy each tool's description and schema across, and call your existing handler from the new handler. Your business functions do not move. Only their declarations do.
What do I get after I deploy?
Add the config file, deploy, and the server that ran only for you now runs for anyone you allow.
{ "name": "crm-tools", "slug": "crm-tools", "runtime": "bun", "connector": true }Deploy from Claude Code or the CLI (denkops deploy), and you get back a live URL like https://crm-tools.denkops.host/mcp and an API key. The "connector": true flag turns on a hosted OAuth stack: discovery metadata, dynamic client registration, and a login and consent screen, none of which you write. Your app code sees only authorized calls. The full flag walkthrough is in "connector: true" is a full OAuth stack.
Now a teammate pastes that one URL into their assistant, the client discovers OAuth and walks them through consent, and they are calling your tools. Same handlers, same schemas, but the server is not tied to your laptop anymore. To wire up the first client, see add your connector to Claude, and /deploy-mcp-server to start from scratch.