Content
<p align="center">
<img src="assets/logo.svg" width="160" alt="RibbonSmith logo — a smith's hammer forging a ribbon command button on an anvil"/>
</p>
<h1 align="center">RibbonSmith</h1>
<p align="center">
<b>Forge Dynamics 365 command bars, declaratively.</b><br/>
An MCP server that gives AI coding agents (and you) safe, validated,
revertible ribbon customization — the way the classic Ribbon Workbench did it.
</p>
---
RibbonSmith talks to your Dataverse environment with plain Web API calls under
your own identity. **Nothing is installed in the environment**; a small
unmanaged container solution (`RibbonEditMCP_<entity>`) is created per edited
entity to carry the customizations — the same "workspace solution" mechanism
the Ribbon Workbench used.
## Why
Hand-writing `RibbonDiffXml` fails easily: ids, locations, template aliases and
element order must all be exactly right, and the feedback loop (import → publish
→ check) is slow. RibbonSmith closes the loop for an agent:
- **Grounding** — read tools return the *real composed ribbon* (every valid
location, control id, command and sequence) as compact JSON.
- **Declarative writes** — `ribbon_add_button` & friends generate schema-correct
XML; the escape hatch `ribbon_edit_diff` accepts raw XML for advanced cases.
- **Validation before anything touches the server** — references, locations,
element order, web-resource existence, unsupported-entity blocklist.
- **Transactional publish** — validate → backup → async import → publish →
*verify the change is actually live* by re-reading the composed ribbon.
- **Instant revert** — every checkout and publish snapshots a restorable backup.
## Setup
Prerequisites: Node.js ≥ 20 and a Dataverse user with permission to create/
import solutions and publish (e.g. System Administrator / System Customizer).
```bash
npm install
npm run build
```
Register with Claude Code:
```bash
claude mcp add ribbonsmith --env RIBBON_MCP_ENV_URL=https://yourorg.crm4.dynamics.com -- node <absolute-path>/ribbonsmith/dist/index.js
```
Or copy [.mcp.json.example](.mcp.json.example) to your project's `.mcp.json`.
### Authentication
On the first call that needs the network, RibbonSmith acquires a token using
the first strategy that works (override with `RIBBON_MCP_AUTH`):
| Strategy | `RIBBON_MCP_AUTH` | How it works |
|---|---|---|
| Service principal | `clientsecret` | Set `RIBBON_MCP_TENANT_ID`, `RIBBON_MCP_CLIENT_ID`, `RIBBON_MCP_CLIENT_SECRET`. For CI/pipelines; the app registration needs an application user in the environment. |
| Azure CLI | `azcli` | Reuses your `az login` session (requires Azure CLI). |
| Browser SSO | `interactive` | Opens your browser for a normal Microsoft Entra sign-in (auth code + PKCE on a localhost loopback), then caches and silently refreshes tokens — the same sign-in experience as Microsoft's official Dataverse MCP local proxy. |
In `auto` mode (default) the order is: `clientsecret` (if env vars present) →
`azcli` (if available) → `interactive`. Tokens from the interactive flow are
cached in the workspace directory; delete `token-cache.json` to sign out.
The interactive flow uses the public client id Microsoft ships in its own
Dataverse QuickStart samples (`51f81489-…`), requesting the standard
`user_impersonation` delegated permission — so unlike the official Dataverse
MCP server's proxy, **no tenant admin consent or Power Platform admin center
enablement is required**. If your tenant restricts that client id, register
your own public client app (redirect URI `http://localhost`, Dynamics CRM
`user_impersonation` permission) and set `RIBBON_MCP_CLIENT_ID`.
## The edit lifecycle
```
ribbon_checkout ──► edit tools (local only) ──► ribbon_preview_diff ──► ribbon_publish
│ │
└── backup (restorable) backup (pre-publish) ──────────────┘
ribbon_restore_backup ◄── revert anytime
```
1. **`ribbon_checkout`** `{ entity: "account" }` — snapshots the current ribbon
customizations (backup) and creates a local working copy of the entity's
`RibbonDiffXml`.
2. **Edit locally** — none of these touch the environment:
`ribbon_get_structure` (your map of valid targets), `ribbon_add_button`,
`ribbon_hide_control`, `ribbon_customize_command` (copy an out-of-the-box
command into the diff to modify it), `ribbon_edit_diff` (validated raw XML),
`ribbon_remove_customization`, `ribbon_discard`.
3. **`ribbon_preview_diff`** — the exact XML that will be published + full
validation report.
4. **`ribbon_publish`** — validates, backs up, submits an **async** solution
import, publishes, then verifies the controls are live (or hidden) in the
freshly retrieved composed ribbon. Server-side imports take 1–3 minutes; if
the import outlasts `waitSeconds` (default 120), the tool returns
`status: "importing"` — call `ribbon_publish_status` to complete it.
5. **`ribbon_restore_backup`** — re-import any backup and publish: full revert.
Working files and backups live under `~/.dataverse-ribbon-mcp/<env-host>/`
(override with `RIBBON_MCP_WORKSPACE`). Everything is a plain file; worst case,
import a backup zip manually through the maker portal.
## Example: agent session
> **User:** Add a "Send to SAP" button on the account form that calls
> `new_/js/sap.js: sendToSap(recordId)`, only for existing records.
```
ribbon_checkout { entity: "account" }
ribbon_get_structure { entity: "account", location: "Form" }
ribbon_add_button {
entity: "account",
id: "new_.account.SendToSap.Button",
location: "Mscrm.Form.account.MainTab.Save.Controls._children",
label: "Send to SAP",
sequence: 45,
modernImage: "ExportToExcel",
action: { type: "JavaScriptFunction", library: "new_/js/sap.js",
functionName: "sendToSap",
parameters: [{ type: "CrmParameter", value: "FirstPrimaryItemId" }] },
enableRules: [{ type: "FormStateRule", id: "new_.account.SendToSap.Existing",
state: "Existing", default: true }]
}
ribbon_preview_diff { entity: "account" }
ribbon_publish { entity: "account" }
→ { status: "published", verification: { status: "verified" }, backupId: "..." }
```
## Tool reference
| Tool | Network | Purpose |
|---|---|---|
| `ribbon_status` | yes | WhoAmI, checkouts, recent backups |
| `ribbon_checkout` | yes | Begin editing; snapshot backup + local working copy |
| `ribbon_get_structure` | cached | Composed ribbon as JSON (tabs → groups → controls) |
| `ribbon_get_command` | cached | One command's actions + rules (for reuse/customization) |
| `ribbon_get_diff` | no | Current working `RibbonDiffXml` + summary |
| `ribbon_add_button` | no* | Declarative button/command/rules/labels |
| `ribbon_hide_control` | no* | `HideCustomAction` for an existing control |
| `ribbon_customize_command` | no* | Copy an OOTB command into the diff |
| `ribbon_edit_diff` | no* | Replace the whole working diff (validated) |
| `ribbon_remove_customization` | no | Remove one element from the diff by id |
| `ribbon_preview_diff` | no* | Diff XML + validation report |
| `ribbon_publish` | yes | Validate → backup → async import → publish → verify |
| `ribbon_publish_status` | yes | Poll/complete a pending publish or restore import |
| `ribbon_discard` | no | Reset working copy to last exported state |
| `ribbon_list_backups` | no | List restorable snapshots |
| `ribbon_restore_backup` | yes | Re-import a snapshot + publish (revert) |
\* validation may consult the cached composed ribbon and check web-resource
existence over the network.
## Safety model
- Every `ribbon_checkout` and every `ribbon_publish` writes a timestamped
solution zip **before** any change; `ribbon_restore_backup` re-imports it.
- Publishes only ever touch the `RibbonDiffXml` node inside a fresh export of
the container solution — entity metadata is never round-tripped
(the entity ships as an `unmodified="1"` shell).
- Validation blocks publishes on: malformed XML, wrong element order, missing
`Mscrm.Templates`, duplicate ids, unresolved command/rule/label references,
missing web resources, unsupported system entities.
- Import failures surface the importjob's own error text; imports are
transactional server-side, so a failed import changes nothing.
## Status & limitations
- **Entity ribbons: stable.** The full lifecycle (add → publish → verify →
hide → publish → verify → restore → verify) is covered by a live E2E suite
(`test/e2e/run-e2e.ts`) plus 56 unit tests over a real composed ribbon.
- **Application ribbon (`APPLICATION`): EXPERIMENTAL.** Implemented per the
documented schema (component type 50, diff under `ImportExportXml`) but not
yet covered by the live E2E suite. Keep backup ids at hand.
- Classic `RibbonDiffXml` only: commands built with the modern Power Apps
command designer (Power Fx / `appaction`) are not read or written. Classic
customizations render fine in Unified Interface.
- Flyout/Menu/group/tab creation goes through `ribbon_edit_diff` (raw XML).
- One editor per entity at a time is assumed (shared container solution).
## Development
```bash
npm test # unit tests (vitest)
npx tsc --noEmit # typecheck
npx tsx test/e2e/run-e2e.ts https://yourorg.crm4.dynamics.com
# live E2E — modifies + restores the
# 'contact' ribbon in that environment!
```
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — module map, data flows, why
async import, testing strategy.
- [docs/RIBBON-CONCEPTS.md](docs/RIBBON-CONCEPTS.md) — a primer on ribbon XML
(composed ribbon vs diff, locations, commands/rules, Command Checker).
## Acknowledgments
RibbonSmith re-implements, as MCP tools, the declarative editing model
pioneered by **Scott Durow's Ribbon Workbench** — for years the way humans
customized Dynamics ribbons safely. This project shares no code with it; the
mechanism (workspace solution → RibbonDiffXml splice → import → publish) was
studied from its observable behavior and from Microsoft's public documentation
of the ribbon schemas and solution APIs.
## License
[MIT](LICENSE)