SLOPSHOPPER

tenant-ops

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

newpanebandcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tenant-ops
│ ┃ Last elicitation ✕ › fix the failing auth test and add an audit log call │ ┃ No elicitation seen this session │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /elicit │ ⎿ tenant-ops: Opened the last-elicitation pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Last elicitation
No elicitation seen this session
README

Tenant Ops 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.

Install

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.

Tool list

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.

Manual connection without the plugin

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.

Upgrade from the previous plugin

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.

Use and remove

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.

Several customers on one machine

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.

Updates and compatibility

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.

Managed rollout

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.

Supported clients

ClientSupport
Claude Code with pluginConnector, skills, commands, approval pane, optional default READ rules
Claude Code without pluginConnector only; server sends the full approval message
Claude DesktopBest effort connector only; full approval message
claude.ai connectorsBest effort connector only; full approval message
VS CodeBest effort connector only; full approval message

Uninstall

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.

Source 2 files
hooks/register.tsx 375 lines
1import 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};
375
types/index.d.ts 50 lines
1// 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