create-module
暂无简介
- License 许可
- MIT
- 所属仓库
- Miragon/pulse
- 最近更新
- 2026-11-14
- Stars
- ★ 8
- Forks
- 0
03 SKILL.md 正文
create-module: your own connector module
A module is a workspace package that exports a module-definition object
conforming structurally to the server's port (the ModuleDefinition type
in server/src/setup.ts). Your module never imports the server, and the
server never reaches into your module. Modules are peers: they never import
each other; anything shared goes through @miragon/pulse-ui (the design kit),
@miragon/pulse-widget-shell or the server-wired SharedResources (today empty).
Reference implementation: modules/mcp-notes. Copy it rather than
inventing a new shape; every step below points at the file that shows the
pattern. For patterns the notes module doesn't cover (toolsets, paged
lists), the published engine module ships its sources under
server/node_modules/@miragon/pulse-engine-connector/src/
(a gitignored symlink: search that package directory, not the repo root; see
CLAUDE.md → Reference code).
A module is for your other systems, not for the engine. The engine
module already registers the generic engine operations as tools
(engine_start_process_instance, engine_correlate_message,
engine_set_job_retries and more), so "start our leasing process" needs no
new tool. A listing shows only the running toolset: these writes appear with
OAuth or under MCP_ACTIVE_MODULES=engine:operations,<yours>, never
in the default read-only boot. List them in the MCP Inspector pnpm dev
starts, or headless from server/ with its CLI
(pnpm exec mcp-inspector --cli --server-url http://127.0.0.1:8400/mcp --transport http --method tools/list). To give the model your processes'
keys and variables, put them into your module's instructions (Step 3)
without naming engine_* tools: the engine module may be off.
Step 1: scaffold the package
cp -R modules/mcp-notes modules/mcp-<name>
pnpm-workspace.yaml already covers modules/*, so no workspace edit is
needed. Then adjust:
package.json: name it@<your-scope>/mcp-<name>and keep the two-entryexportsmap exactly:.(compiled server code) and./widgets(pointing at TypeScript source, compiled by the server's Vite build). Keepreactan optional peer: server-side consumers must not need it.- Keep the three tsconfigs:
tsconfig.jsonexcludessrc/widgetsfrom the server build;tsconfig.widgets.jsontype-checks the widget.tsx(wired into thetypecheckscript, the only check that covers widget code); andsrc/widgets/tsconfig.jsonextends it, so editors, which read only files namedtsconfig.json, check the widgets with the same settings. - A module without widgets (its views are finished HTML documents, the
add-widgetskill) deletessrc/widgetsand drops the./widgetsentry,tsconfig.widgets.jsonand the second half oftypecheck, which would fail on no inputs (TS18003). - Do a global rename
notes→<name>acrosssrc/(module name, plugin name, tool prefixes and thus view names, query keys),src/module.test.tsand the widget test included.
The copy carries its own pins of @miragon/pulse-ui,
@miragon/pulse-widget-shell and the shared libraries, and they
move with the server's: an upgrade bumps them in every module too.
server/test/pins.test.ts names each pin that differs from the installed
@miragon/pulse-widget-shell.
Step 2: the data layer
src/notes-store.ts shows the shape: tools and widgets only ever see an
interface; the implementation behind it is yours (an HTTP client, an SDK, a
DB). Keep the client code in this layer, so handlers stay thin. If the client
grows real weight (generated SDK, query library), split it into its own
package later: extract on the second consumer, not speculatively.
The client talks to your system, never to a Camunda engine: a second engine
client built from ENGINE_BASE_URL skips what the engine module does on every
call (the engine pick by engine and ENGINE_DEFAULT, each engine's auth
including passthrough, its error rules). If the engine tools do not cover a
need, stop and ask: custom modules have no supported engine seam yet.
Step 3: the module definition (src/module.ts)
The contract, field by field (see notesModule):
name: the key used inMCP_ACTIVE_MODULESand the name of its plugin.configFromEnv(env): a pure env → raw-config mapping; no I/O, noprocess.envreads outside the argument. It may check the config and throw one plainErrorthat names the variable, as the engine module does: a wrong setting then fails the boot before itsToolsets:line.knownEnvVars: every env var your module reads. This feeds the server's boot-time typo warner, and each var contributes its prefix (ACME_URL→ACME_) to the watched set, so your module gets the same typo coverage as the built-in ones. A variable whose name your own settings carry (the engine module's profiles nameENGINE_SECRET_*variables) goes into the optionalreferencedEnvVars(env)instead, which never throws.toolsets: required as soon as your module registers a tool withoutreadOnlyHint: trueand the server may run without OAuth (Step 5). A module of reads only, like notes, leaves it out; a<name>:<toolset>suffix on it is ignored with a warning.createPlugin(config, shared): validateconfigwith your zod schema (also after a check inconfigFromEnv) and return aModulePlugin(the type from@miragon/pulse-widget-shell/server),{ name, register(server) }.serveris aModuleServer: the official MCP SDK's ownregisterTool/registerPrompt/registerResource, with the signatures its docs (https://ts.sdk.modelcontextprotocol.io/v2/) show. The boot serves your views from the bundle, so a module needs no resource for a widget; a finished HTML view is the exception (theadd-widgetskill, "A finished HTML view"). Construct your data source/client once increatePlugin;registerhands it toregisterTools, then toregisterWidgetTools.nameis the module's name: the boot refuses a tool, prompt or resource name or a resource URI two plugins register and names both owners, and an input schema that would let an unknown key through. The toolset filter never sees a resource, so every client that reaches the server can read it: register one that only a wider toolset may read inside that toolset's branch.sharedis the port for what the modules of this server share, built bysharedResourcesinserver/src/setup.ts, which hands nothing through today: a field you add toSharedResourcesthere, your module types structurally on its side and tolerates its absence.- Optional
bootWarnings(env): return actionable hints for misconfigured deployments (see the engine module's warning when no engine is configured). - Optional
instructions(config): a short snippet the server appends to its MCPinstructions(the rules that hold for EVERY tool of your module: routing, argument conventions, which tool answers what), so tool descriptions stay one-liners.configcarries the effectivetoolset; returnundefinedto add nothing.
src/index.ts, the package root, exports the module definition and only what
a root wires (notesModule); createPlugin, the config and the data types stay
inside the package, as in the Miragon connectors.
src/module.test.ts checks the port the way the boot uses it: the plugin's
name and the tools its register adds, recorded by recordingModuleServer()
from @miragon/pulse-widget-shell/testing, which also calls a handler and reads
a resource (recording.resource(uri).read()) the way the SDK does. Keep it in
step with your tools.
Step 4: tools
src/tools.ts holds the plain tools for the model, registered through
createToolRegistrar(server, store) (from
@miragon/pulse-widget-shell/server). The second argument is threaded into
every handler as its first parameter; the registrar refuses unknown keys.
A raw server.registerTool() is reserved for the widget path (widget-tools.ts).
Tool conventions (they carry the contract, so they are not cosmetic):
- Names are
<module>_<verb>_<noun>in snake_case. Never use_show_or a_datasuffix for a plain tool: the server's wire-contract e2e test asserts widget_metaon every*_show_*tool and app-only visibility on every*_datafeed, by name, across all composed modules including yours. - Describe an input field only where it means more than its name; a rule every tool of your
module shares goes once into
instructions(Step 3), which the server sends with every session. - Annotations: reads get
{ readOnlyHint: true, idempotentHint: true }(+openWorldHint: truewhen they talk to an external system); writes dropreadOnlyHintand need toolsets (Step 5); every write statesdestructiveHintexplicitly (MCP reads an absent hint astrue):truefor irreversible operations,falseotherwise. - Write tools that only flip state return a small
{ success: true, … }object, not the raw (often empty) upstream response.
Step 5: toolsets (as soon as a tool writes)
A module without toolsets registers every tool whenever it runs. Without OAuth
nobody signs in, so each of its writes is open to anyone who reaches the
port; the boot warns once per tool without readOnlyHint: true, naming the
tool and your module. So take this step as soon as your module registers a
tool without readOnlyHint: true and the server may run without OAuth; a
module of reads only skips it. Declare a vocabulary and put it
on the module definition. The boot resolves ONE concrete toolset per
boot, logs it, and hands it to createPlugin as config.toolset:
import { createToolsetVocabulary } from "@miragon/pulse-widget-shell/server"
export const MY_TOOLSETS = ["read-only", "standard"] as const
export const myToolsets = createToolsetVocabulary("<name>", MY_TOOLSETS, "read-only", {
authenticatedDefault: "standard",
})
// src/module.ts: `toolsets: myToolsets,` on the module definition
- Accept it in your config schema (
toolset: z.string().optional()) and resolve it once increatePluginwithmyToolsets.resolve(config.toolset). - The third argument is your READ-ONLY FLOOR (it must permit no write). No suffix on a boot without OAuth, an empty or unknown suffix
(with a warning), and a direct
createPlugincaller passing no toolset all land there. authenticatedDefaultis the no-suffix default under OAuth, which the boot installs fromMCP_OAUTH_*, so every OAuth boot that names no suffix runs it: your standard non-admin toolset. Never make it your widest one: an admin-like toolset must only be reachable by naming it.- Write ONE rule function for the module,
isToolInToolset(config, toolset)over the toolsetresolvereturned: admin-only names first, then the annotations (read-only ⇔readOnlyHint: true). Apply it through ONE registrar wrapper,withToolsetFilter, so every tool, writes included, registers through the registrar and the filter decides it. Never a scatteredtoolset === "read-only"compare (fails open for every name you add later) or atoolset === undefinedshortcut (an absent toolset is the floor, not "everything"). Reference:server/node_modules/@miragon/pulse-engine-connector/src/lib/mcp/toolsets.ts.
Step 6: wire the module into the server
Four edits, plus the env documentation:
server/package.json: add"@<your-scope>/mcp-<name>": "workspace:*", thenpnpm install.server/src/setup.ts: add your module toMODULES.server/src/ui/view-registry.ts: spread your<name>Viewsmap intoviewRegistry(needed once you have show tools; harmless before).MCP_ACTIVE_MODULESin.env.exampleand in your.env: add<name>to the list. A module the list does not name stays off and its tools are absent (the boot log'sToolsets:line shows what runs).server/test/env-example.test.tsholds.env.exampletoMODULES; nothing checks your.env, so a stale copy boots without your module.
A missed spread is silent (tools work, the view says it can't show
itself), which is why server/test/view-registry.test.ts covers you
without an edit: it boots the real composition (edit 2) and fails naming a
view-bound tool without a view. A module whose views are finished HTML documents
has nothing to spread: it skips edit 3 and the Tailwind line below.
Tailwind needs no edit: server/src/ui/globals.css scans every workspace
module through the modules/*/src glob. Only an npm-installed module
package with widget sources would need its own @source line there.
Then document every var from knownEnvVars in .env.example.
server/test/env-example.test.ts fails on both drift directions (an
undocumented var, or a documented var no module reads).
The other guard tests in server/test/ need no edit either: they boot the
composed server in-process and derive what they check from MODULES and the
tools it lists, so they cover your module by naming convention and on every
toolset it declares. Only server/test/example-notes.test.ts names the
example module, and it goes with it (see "Remove the example module").
Step 7: verify
pnpm build && pnpm typecheck && pnpm test
Then boot it for real: pnpm dev, check the boot log (your module's env-var
typo coverage and boot warnings show up here, and a warning names each write
a module without toolsets leaves open), and call your tools in the MCP
Inspector pnpm dev starts (open the URL it prints), or headless with its CLI
(CLAUDE.md → Verification). Also verify
the selection path: MCP_ACTIVE_MODULES=<name> pnpm dev must expose exactly
your module's tools (the shell's value beats .env). If you declared toolsets, that is its read-only floor
without OAuth and its authenticatedDefault with it (the boot log's
Toolsets: line says which).
Remove the example module
modules/mcp-notes is an example to copy, not part of your server. Once your
own module stands, take the example out in one change:
server/src/setup.ts: dropnotesModulefromMODULESand its import.server/src/ui/view-registry.ts: drop thenotesViewsspread and its import.server/package.json: drop the@acme/mcp-notesdependency and the notes indescription; deletemodules/mcp-notes, thenpnpm install..env.example: drop the Notes block (NOTES_TITLE),notesfrom the liveMCP_ACTIVE_MODULESvalue and every othernotesmention (that variable's comment). Take it out of your.envtoo: the boot refuses a name the list still holds onceMODULESno longer does.server/src/app.ts:SERVER_INSTRUCTIONStells the model the server holds team notes; say what yours does.- Delete
server/test/example-notes.test.ts, the one test that names the example. - The smoke checks that name
notes_show_notes(CLAUDE.md→ Commands and Verification, thesetup-serverskill's Step 1, the README's Quickstart) take a show tool of yours, and the README's Configuration names your module where it names notes (the toolset prose and the variable table). - Every module list that still names
notesnames your module: the README's Quickstart (MCP_ACTIVE_MODULES=engine,notes),CLAUDE.md(engine:operations,notesand the comment oncp .env.example .env) and thesetup-serverskill (Steps 1, 2, 3 and 5, Troubleshooting). Copied as it stands, such a value fails the boot.git grep -n notesshows what is left, including the comment inserver/src/setup.tsthat names the deleted test.
Then run Step 7's bar. The other guard tests follow MODULES, so they need no
edit.
Anti-patterns
- Importing another module (or the server) instead of conforming structurally: peer isolation is the architecture.
- Reading
process.envanywhere in the module butconfigFromEnv's argument, or letting a check there throw a ZodError: the boot prints its schema path (engines[0].id), not the variable to fix. - Registering plain tools with a raw
server.registerTool(): the registrar is the contract; the raw path is for widget tools only. - Naming a plain tool
*_dataor*_show_*: the e2e naming contract will fail it, correctly. - Talking to your backend from widget code: widgets read through
*_datafeeds only, via the view runtime's query hooks (see the add-widget skill).
04 同仓库更多 Skills
setup-server
Set up, configure, run, brand, and deploy this composed MCP server. Use whenever connecting a Camunda 7 / CIB seven engine, selecting modules or toolsets (`MCP_ACTIVE_MODULES`), renaming the `@acme` scope, building the Docker image, running behind an MCP gateway, or troubleshooting boot warnings or inspector issues.
add-widget
Step-by-step house pattern for adding an interactive widget to a module (the React component built from the design kit `@miragon/pulse-ui` and widget-shell's view runtime, the `show_*` widget tool, the app-only `*_data` feed, the view map keyed by the show tool's name, and a widget test under `TestViewHost`). Use whenever adding or changing widgets, `show_*` tools, `*_data` feeds, in-widget data fetching, or when debugging a widget that renders empty, unstyled, or hangs on "Loading…".
pulse-cli
Inspect and operate Camunda 7, Operaton and CIB seven process engines through their REST API with the Pulse CLI (deployments, process instances, user tasks, external tasks, incidents, jobs, history, variables, decisions), and drive process instances with its workflow commands (inspect, wait, advance, retry, deploy, status). Use it whenever a task needs to read or change the state of such an engine.
shadcn
Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
mcp-apps-builder
Where to look before changing MCP server code in this repo (tools, resources, prompts, widgets, auth). Points to the house skills, the CLAUDE.md invariants, widget-shell's view runtime and the official MCP SDK's typings and docs; it teaches no framework API itself.
create-ticket
Create or update a GitHub issue for Pulse using the `gh` CLI. Use when the user asks to "file a bug", "create a feature request", "open a GitHub issue", or "update an existing one". Supports feature, bug, and refactor issue types with structured templates; shows a draft for confirmation before creating or editing; looks up existing issues by number or URL for updates.