Tenant Ops usage guidance, setup, status and approval pane for Claude Code

This plugin adds Tenant Ops usage guidance, setup and status commands, a shell-command reminder, and the approval pane. Connect the MCP server separately after installing it.
Add the Tenant Ops marketplace, install the plugin, then run its setup command in Claude Code:
claude plugin marketplace add /path/to/marketplace-repository
claude plugin install tenant-ops@tenant-ops
Run /tenant-ops:setup-project. Choose a connection scope and provide the complete HTTPS endpoint ending in /mcp or /mcp/full. The command names the server tenant-ops and sends the approval-pane header. Claude Code handles OAuth; sign in when prompted. Run /tenant-ops:status to check the read-only ping response.
The connection scopes are project shared (.mcp.json), local to this project, and user wide. The command can also add a marked guidance block to this project's CLAUDE.md or the global Claude CLAUDE.md. Rerunning it preserves text outside that block. A local entry takes precedence over a project entry, which takes precedence over a user entry.
Use /mcp/full when tools are loaded on demand. In Claude Code, /context can label tools as (deferred) or (loaded on-demand). Use /mcp when every tool definition loads at once, such as on Microsoft Foundry hosted on Azure or when ENABLE_TOOL_SEARCH=false. See Claude Code's tool search guidance for current behavior.
Use the same server name so tool names stay consistent. Replace the address with your endpoint:
claude mcp add --transport http --scope user tenant-ops https://mcp.example.com/mcp
This manual entry does not request the plugin's approval pane. The plugin setup command adds that header automatically.
Uninstall m365-ops@tenant-ops, install tenant-ops@tenant-ops, then run /tenant-ops:setup-project to create the direct connection. Update permission rules from mcp__plugin_m365-ops_tenant-ops__* to mcp__tenant-ops__*. The old plugin's bundled server stays active until the old plugin is uninstalled.
The tenant-ops skill guides reads, previews, approvals, writes, and verification. The PreToolUse hook reminds you when a shell command accesses Microsoft 365 tenant data and names a matching Tenant Ops tool when known; it does not change or block the command. The approval pane shows full consent details while a form is open. It needs a terminal at least 144 columns wide; run /elicit to reopen the last request. Disable elicit-fulltext@ccdf-mods to avoid two panes for one form.
To remove the connection, guidance, and setup-owned rules, run /tenant-ops:remove before uninstalling the plugin.
Keep one folder per customer and connect tenant-ops at local or project scope in each folder. Keep user scope empty, or reserve it for a home tenant. Each session uses one tenant; /tenant-ops:status shows the folder's effective server address and the signed-in tenant.
Third-party marketplaces do not update automatically by default. Turn on auto-update for the tenant-ops marketplace in /plugin, or run claude plugin update tenant-ops@tenant-ops. Rerun /tenant-ops:setup-project after updating so the connection header and READ rules match the installed plugin. This plugin needs a Tenant Ops server with plugin-version support to warn about outdated plugins. A compatible server shows a Plugin: line in /tenant-ops:status. Earlier servers still work but cannot warn about outdated plugins.
Customer IT can adapt the managed-settings template. It defines a managed HTTP tenant-ops connector, the version and pane headers, READ permissions, the marketplace, and the enabled plugin. Claude Code 2.1.259 or later is required. A managed entry takes precedence over personal entries and users cannot remove it. Header values are readable by users, so use only non-secret values. Setup recognizes the managed entry and skips connector creation.
| Client | Support |
|---|---|
| Claude Code with plugin | Connector, skills, commands, approval pane, optional default READ rules |
| Claude Code without plugin | Connector only; server sends the full approval message |
| Claude Desktop | Best effort connector only; full approval message |
| claude.ai connectors | Best effort connector only; full approval message |
| VS Code | Best effort connector only; full approval message |
Run /tenant-ops:remove first, choose the scopes to remove, and confirm in chat. Then uninstall tenant-ops@tenant-ops. If the plugin was already uninstalled, use claude mcp remove --scope <local|user> tenant-ops for personal entries or remove only the tenant-ops key from the project's .mcp.json. An organization-managed entry must be removed by IT.
hooks/register.tsx 375 lines1import type { ElementTable, Register } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4import type { ApprovalPaneCard, ElicitFulltextRequest } from "../types";
5
6const PANE = "tenant-ops-approval";
7
8// Session state only (never $.store, never a file): the message may be sensitive.
9const current = atom({ plugin: "tenant-ops", key: "current" } as const, null);
10const last = atom({ plugin: "tenant-ops", key: "last" } as const, null);
11
12const TENANT_OPS_SERVER = "tenant-ops";
13const HTTP_SERVER_PREFIX = /^m365-http-/;
14
15export const isTenantOpsServer = (name: string): boolean =>
16 name === TENANT_OPS_SERVER || HTTP_SERVER_PREFIX.test(name);
17
18const approvalCard = (schema: Record<string, unknown> | undefined): ApprovalPaneCard | null => {
19 const value = schema?.["x-m365-approval-card"];
20 if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
21 const card = value as Record<string, unknown>;
22 return card.v === 1 && (card.kind === "single" || card.kind === "batch")
23 ? (card as ApprovalPaneCard)
24 : null;
25};
26
27const when = (ms: number): string => new Date(ms).toISOString();
28
29export type SegmentStyle = {
30 color?: string;
31 bold?: boolean;
32 dimColor?: boolean;
33 strikethrough?: boolean;
34 underline?: boolean;
35};
36export type Segment = { text: string; style: SegmentStyle };
37
38const plain = (text: string): Segment => ({ text, style: {} });
39const whole = (line: string, style: SegmentStyle): Segment[] => [{ text: line, style }];
40
41const URL_RE = /https?:\/\/\S+/g;
42// The arrow and its spaces are captured (group 4) so no character is lost.
43const CHANGE_RE = /^(\s*-\s+)([^:]+:\s*)([\s\S]*?)(\s+->\s+)([\s\S]*)$/;
44
45// Styles one line. First matching rule wins. Every branch is built from slices
46// of `line` only, so the segment texts always concatenate back to `line`.
47const styleLine = (line: string, index: number): Segment[] => {
48 if (index === 0 && /^\s*Confirm:/.test(line)) {
49 return whole(line, { bold: true });
50 }
51
52 const target = /^(\s*Target:)([\s\S]*)$/.exec(line);
53 if (target !== null) {
54 return [
55 { text: target[1], style: { bold: true } },
56 { text: target[2], style: { bold: true, color: "cyan" } },
57 ];
58 }
59
60 if (/^\s*(DANGER|DESTRUCTIVE):/.test(line)) {
61 return whole(line, { bold: true, color: "red" });
62 }
63 if (/^\s*(CAUTION|WARNING):/.test(line)) {
64 return whole(line, { color: "yellow" });
65 }
66 if (/^\s*Changes:\s*$/.test(line)) {
67 return whole(line, { bold: true });
68 }
69
70 const change = CHANGE_RE.exec(line);
71 if (change !== null) {
72 return [
73 plain(change[1]),
74 { text: change[2], style: { dimColor: true } },
75 { text: change[3], style: { color: "red", strikethrough: true } },
76 plain(change[4]),
77 { text: change[5], style: { color: "green" } },
78 ];
79 }
80
81 const segments: Segment[] = [];
82 let from = 0;
83 for (const match of line.matchAll(URL_RE)) {
84 const at = match.index ?? 0;
85 segments.push(plain(line.slice(from, at)));
86 segments.push({ text: match[0], style: { underline: true } });
87 from = at + match[0].length;
88 }
89 segments.push(plain(line.slice(from)));
90
91 return segments;
92};
93
94/**
95 * Splits `message` on "\n" and styles each line as a list of segments.
96 * Lossless by construction: per line, the segment texts joined equal the line.
97 */
98export const styleLines = (message: string): Segment[][] =>
99 message.split("\n").map((line, index) => styleLine(line, index).filter((s) => s.text !== ""));
100
101/** Style structured fields without re-parsing values that may contain arrows. */
102export const cardStyledLines = (card: ApprovalPaneCard): Segment[][] => {
103 const lines: Segment[][] = [];
104 const push = (line: string) =>
105 lines.push(styleLine(line, lines.length).filter((s) => s.text !== ""));
106 const warning = (line: string) => {
107 const severity = /^\s*(DANGER|DESTRUCTIVE|CAUTION|WARNING):/.test(line);
108 lines.push(severity ? styleLine(line, lines.length) : whole(line, { color: "yellow" }));
109 };
110 push(card.title);
111 if (card.kind === "single" && card.target !== undefined) push(`Target: ${card.target}`);
112 push(`Irreversible: ${card.irreversible ? "yes" : "no"}`);
113 card.warnings.forEach(warning);
114 if (card.kind === "single") {
115 if (card.changes !== undefined && card.changes.length > 0) {
116 push("Changes:");
117 for (const change of card.changes) {
118 lines.push(
119 [
120 plain("- "),
121 { text: `${change.field}: `, style: { dimColor: true } },
122 { text: change.from ?? "", style: { color: "red", strikethrough: true } },
123 plain(" -> "),
124 { text: change.to, style: { color: "green" } },
125 ].filter((s) => s.text !== ""),
126 );
127 }
128 }
129 } else {
130 for (const [index, operation] of (card.operations ?? []).entries()) {
131 push(`Operation ${index + 1}: ${operation.title} (${operation.tool})`);
132 push(`Status: ${operation.status}; Excluded: ${operation.excluded ? "yes" : "no"}`);
133 if (operation.target !== undefined) push(`Target: ${operation.target}`);
134 push(`Args: ${operation.args}`);
135 operation.warnings.forEach(warning);
136 }
137 }
138 if (card.link !== undefined) push(`Link: ${card.link}`);
139 return lines;
140};
141
142export const cardMessage = (card: ApprovalPaneCard): string =>
143 cardStyledLines(card)
144 .map((line) => line.map((segment) => segment.text).join(""))
145 .join("\n");
146
147const innerWidth = (bodyColumns: number, bordered: boolean): number =>
148 bordered ? bodyColumns - 4 : bodyColumns;
149
150export const paneScrollButtons = (
151 scroll: (args: { in: string; to: "start" | "end" }) => Promise<unknown>,
152) => ({
153 end: {
154 key: "elicit-end",
155 label: "End",
156 hotkey: "e",
157 onPress: () => scroll({ in: PANE, to: "end" }),
158 },
159 top: {
160 key: "elicit-top",
161 label: "Top",
162 hotkey: "t",
163 onPress: () => scroll({ in: PANE, to: "start" }),
164 },
165});
166
167// Rows use slices of the original styled segments. Long non-space runs begin
168// on a fresh row, then fill exact-width rows; no content or style is dropped.
169const hardBreak = (segments: Segment[], width: number): Segment[][] => {
170 const source = segments.map((segment) => segment.text).join("");
171 if (source === "") return [[]];
172 const bounds: [number, number][] = [];
173 let start = 0;
174 let end = 0;
175 const flush = () => {
176 if (end > start) bounds.push([start, end]);
177 start = end;
178 };
179 for (const match of source.matchAll(/\S+|\s+/g)) {
180 const runStart = match.index;
181 const runEnd = runStart + match[0].length;
182 if (runEnd - runStart > width && end > start) flush();
183 while (end < runEnd) {
184 if (end - start === width) flush();
185 end = Math.min(runEnd, end + width - (end - start));
186 }
187 }
188 flush();
189
190 return bounds.map(([from, to]) => {
191 const row: Segment[] = [];
192 let offset = 0;
193 for (const segment of segments) {
194 const left = Math.max(from, offset);
195 const right = Math.min(to, offset + segment.text.length);
196 if (right > left) {
197 row.push({ text: segment.text.slice(left - offset, right - offset), style: segment.style });
198 }
199 offset += segment.text.length;
200 }
201 return row;
202 });
203};
204
205// One Text per hard-broken row (a blank line is a one-row Box), each holding
206// one nested Text per styled segment.
207const renderMessage = (
208 { Box, Text }: Pick<ElementTable, "Box" | "Text">,
209 message: string,
210 card: ApprovalPaneCard | null,
211 width: number,
212) =>
213 (card === null ? styleLines(message) : cardStyledLines(card))
214 .flatMap((segments) => hardBreak(segments, width))
215 .map((segments) =>
216 segments.length === 0 ? (
217 <Box height={1} />
218 ) : (
219 <Text wrap="wrap">
220 {segments.map((segment) => (
221 <Text {...segment.style}>{segment.text}</Text>
222 ))}
223 </Text>
224 ),
225 );
226
227export const register: Register = (on) => {
228 on("session.start", async ($, e, next) => {
229 await $.command.register({
230 name: "elicit",
231 description: "Show the full text of the last MCP elicitation request",
232 });
233
234 return next(e);
235 });
236
237 // Observe only: the event is passed on unchanged and its result returned
238 // untouched, so the dialog's decision path is never altered.
239 on("classic.Elicitation", async ($, e, next) => {
240 if (!isTenantOpsServer(e.mcp_server_name)) return next(e);
241 const request: ElicitFulltextRequest = {
242 server: e.mcp_server_name,
243 message: e.message,
244 card: approvalCard(e.requested_schema),
245 url: e.url ?? null,
246 mode: e.mode ?? null,
247 id: e.elicitation_id ?? null,
248 receivedAt: await $.clock.now(),
249 };
250 await update($, current, () => request);
251 await update($, last, () => request);
252 // Probe (0.3.0): the AbovePrompt band is not drawn while the client's own
253 // dialog is open, so also open the pane. Not closed on ElicitationResult.
254 const placed = await $.ui.open({ id: PANE, title: "MCP approval request (full text)" });
255 if (placed.isPlaced === false) {
256 $.ui.toast(
257 "Approval pane not shown: widen the terminal to at least 144 columns or run /elicit.",
258 );
259 }
260
261 return next(e);
262 });
263
264 on("classic.ElicitationResult", async ($, e, next) => {
265 if (!isTenantOpsServer(e.mcp_server_name)) return next(e);
266 await update($, current, () => null);
267 // Close the pane the Elicitation hook opened; `last` is kept so /elicit can
268 // reopen it. A refused close (a ui.close hook denying) must not block next(e).
269 try {
270 await $.ui.close({ id: PANE });
271 } catch {
272 // ignored on purpose: display aid only
273 }
274
275 return next(e);
276 });
277
278 on("command.run", { command: "elicit" }, async ($) => {
279 const placed = await $.ui.open({ id: PANE, title: "Last elicitation" });
280 if (placed.isPlaced === false) {
281 $.ui.toast(
282 "Approval pane not shown: widen the terminal to at least 144 columns or run /elicit.",
283 );
284 }
285
286 return { text: "Opened the last-elicitation pane." };
287 });
288
289 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
290 const request = await read($, current);
291
292 if (request === null) {
293 return next(e);
294 }
295
296 const { Box, Text } = $.ui.resolve(e);
297
298 return (
299 <Box flexDirection="column" width={e.props.bodyColumns}>
300 <Text bold>MCP {request.server} asks:</Text>
301 {renderMessage({ Box, Text }, request.message, request.card, e.props.bodyColumns)}
302 {request.url !== null &&
303 renderMessage({ Box, Text }, request.url, null, e.props.bodyColumns)}
304 </Box>
305 );
306 });
307
308 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
309 // The open request if there is one, else the most recent.
310 const open = await read($, current);
311 const recent = await read($, last);
312 const request = open ?? recent;
313 const result = (recent as (ElicitFulltextRequest & { copyResult?: string }) | null)?.copyResult;
314 const { Box, Button, Text } = $.ui.resolve(e);
315
316 if (request === null) {
317 return <Text dimColor>No elicitation seen this session</Text>;
318 }
319
320 const bordered = open !== null;
321 const width = innerWidth(e.props.bodyColumns, bordered);
322 const copyText =
323 request.card !== null
324 ? cardMessage(request.card)
325 : request.url === null
326 ? request.message
327 : `${request.message}\n${request.url}`;
328 const scrollButtons = paneScrollButtons((args) => $.ui.scroll(args));
329
330 return (
331 <Box
332 flexDirection="column"
333 width={e.props.bodyColumns}
334 paddingX={bordered ? 1 : 0}
335 {...(bordered ? { borderStyle: "round", borderColor: "yellow" } : {})}
336 >
337 <Box flexDirection="row">
338 <Button {...scrollButtons.end} />
339 <Button
340 key="elicit-copy"
341 label="Copy full text"
342 hotkey="c"
343 onPress={async (press) => {
344 const copied = await $.ui.copy({ text: copyText, surface: press.surface });
345 const feedback = copied.isCopied
346 ? `Copied ${copyText.length} characters`
347 : `Copy failed: ${copied.reason}`;
348 await update($, last, (stored) =>
349 stored === null ? null : { ...stored, copyResult: feedback },
350 );
351 }}
352 />
353 </Box>
354 {renderMessage(
355 { Box, Text },
356 "Click a button, or press ctrl+x tab, then e / c / t.",
357 null,
358 width,
359 ).map((line) => (
360 <Text dimColor>{line}</Text>
361 ))}
362 <Text bold>
363 MCP {request.server} asked ({when(request.receivedAt)}):
364 </Text>
365 <Box flexDirection="column" width={width}>
366 {renderMessage({ Box, Text }, request.message, request.card, width)}
367 </Box>
368 {request.url !== null && renderMessage({ Box, Text }, request.url, null, width)}
369 {result !== undefined && <Text>{result}</Text>}
370 <Button {...scrollButtons.top} />
371 </Box>
372 );
373 });
374};
375types/index.d.ts 50 lines1// State contract of the Tenant Ops approval pane. Session-only: nothing here is
2// written to $.store or to disk, because an elicitation message may carry
3// sensitive text.
4export type ApprovalPaneCard = {
5 v: 1;
6 kind: "single" | "batch";
7 title: string;
8 target?: string;
9 irreversible: boolean;
10 warnings: string[];
11 changes?: { field: string; from?: string; to: string }[];
12 operations?: {
13 tool: string;
14 title: string;
15 target?: string;
16 status: "ok" | "warning" | "error";
17 excluded: boolean;
18 args: string;
19 warnings: string[];
20 }[];
21 link?: string;
22};
23
24export type ElicitFulltextRequest = {
25 /** MCP server that asked. */
26 server: string;
27 /** The server's message, complete and untruncated. */
28 message: string;
29 /** Versioned approval details when the server supplied them. */
30 card: ApprovalPaneCard | null;
31 /** Target URL for url-mode requests; null otherwise. */
32 url: string | null;
33 mode: "form" | "url" | null;
34 /** The server's elicitation id, when it sent one. */
35 id: string | null;
36 /** Epoch milliseconds from $.clock.now() when the hook saw the request. */
37 receivedAt: number;
38};
39
40declare module "claude-code" {
41 interface PluginState {
42 "tenant-ops": {
43 /** The request whose dialog is open now; null once answered. */
44 current: ElicitFulltextRequest | null;
45 /** The most recent request this session; kept after it is answered. */
46 last: ElicitFulltextRequest | null;
47 };
48 }
49}
50