@loopops/mcp-server 3.48.0 → 3.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * MCP UI extension support — render tool output as styled HTML so Claude
3
+ * Desktop renders it in a side panel instead of inline.
4
+ *
5
+ * Claude Desktop's `initialize` handshake declares support for the
6
+ * io.modelcontextprotocol/ui extension with `text/html;profile=mcp-app`.
7
+ * When a tool returns an `embedded_resource` with that mime type, Desktop
8
+ * renders it in a side panel with native copy/print/share affordances —
9
+ * the "create a document" UX the team wants for prep briefs and similar
10
+ * long-form artifacts.
11
+ *
12
+ * Clients without the UI extension (Claude Code CLI, Codex) just see the
13
+ * markdown text content block — same content, inline.
14
+ *
15
+ * Usage:
16
+ * server.tool(name, desc, schema, safeToolUi(async args => {
17
+ * const md = await trpcMutation(...);
18
+ * return {
19
+ * markdown: md,
20
+ * title: "Customer Prep — Acme",
21
+ * };
22
+ * }))
23
+ */
24
+ type ContentBlock = {
25
+ type: "text";
26
+ text: string;
27
+ } | {
28
+ type: "resource";
29
+ resource: {
30
+ uri: string;
31
+ mimeType: string;
32
+ text: string;
33
+ };
34
+ };
35
+ type McpToolResult = {
36
+ content: ContentBlock[];
37
+ isError?: boolean;
38
+ };
39
+ interface UiResponse {
40
+ /** Markdown body — always returned as a text content block. */
41
+ markdown: string;
42
+ /** Title shown at the top of the HTML doc + tab. */
43
+ title: string;
44
+ /** Optional meta line under the title (e.g., "generated at … · account …"). */
45
+ meta?: string;
46
+ /** Optional uri identifier. Defaults to a synthesized one. */
47
+ uri?: string;
48
+ }
49
+ /**
50
+ * Tool wrapper that returns content as BOTH markdown (inline fallback)
51
+ * AND HTML (Desktop side-panel render). Mirrors safeTool's error handling.
52
+ *
53
+ * Use this for long-form artifacts where the user wants a document
54
+ * experience: prep briefs, quote previews, deal-health snapshots.
55
+ *
56
+ * Short tool outputs (status messages, counts, single-fact lookups)
57
+ * should stick with plain safeTool — adding HTML would just be noise.
58
+ */
59
+ export declare function safeToolUi<Args>(fn: (args: Args) => Promise<UiResponse>): (args: Args) => Promise<McpToolResult>;
60
+ export {};
@@ -0,0 +1,205 @@
1
+ /**
2
+ * MCP UI extension support — render tool output as styled HTML so Claude
3
+ * Desktop renders it in a side panel instead of inline.
4
+ *
5
+ * Claude Desktop's `initialize` handshake declares support for the
6
+ * io.modelcontextprotocol/ui extension with `text/html;profile=mcp-app`.
7
+ * When a tool returns an `embedded_resource` with that mime type, Desktop
8
+ * renders it in a side panel with native copy/print/share affordances —
9
+ * the "create a document" UX the team wants for prep briefs and similar
10
+ * long-form artifacts.
11
+ *
12
+ * Clients without the UI extension (Claude Code CLI, Codex) just see the
13
+ * markdown text content block — same content, inline.
14
+ *
15
+ * Usage:
16
+ * server.tool(name, desc, schema, safeToolUi(async args => {
17
+ * const md = await trpcMutation(...);
18
+ * return {
19
+ * markdown: md,
20
+ * title: "Customer Prep — Acme",
21
+ * };
22
+ * }))
23
+ */
24
+ import { marked } from "marked";
25
+ import { ApiAuthError, ApiHttpError, ApiNetworkError, ApiTimeoutError, } from "../api-client.js";
26
+ const MCP_APP_MIME = "text/html;profile=mcp-app";
27
+ /**
28
+ * Convert a markdown brief / quote / report into a styled, self-contained
29
+ * HTML document for Desktop's side-panel render.
30
+ *
31
+ * Self-contained means no external assets — all styles inlined. Lets the
32
+ * client cache + share the doc without hot-linking.
33
+ */
34
+ function renderMcpAppHtml(opts) {
35
+ const body = marked.parse(opts.markdown, { gfm: true, breaks: false });
36
+ const safeTitle = escapeHtml(opts.title);
37
+ const safeMeta = opts.meta ? escapeHtml(opts.meta) : "";
38
+ return `<!DOCTYPE html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="utf-8">
42
+ <meta name="viewport" content="width=device-width,initial-scale=1">
43
+ <title>${safeTitle}</title>
44
+ <style>
45
+ :root {
46
+ --bg: #ffffff;
47
+ --fg: #0f172a;
48
+ --muted: #64748b;
49
+ --line: #e2e8f0;
50
+ --code-bg: #f1f5f9;
51
+ --pre-bg: #f8fafc;
52
+ --accent: #0ea5e9;
53
+ --blockquote: #475569;
54
+ }
55
+ @media (prefers-color-scheme: dark) {
56
+ :root {
57
+ --bg: #0f172a;
58
+ --fg: #f1f5f9;
59
+ --muted: #94a3b8;
60
+ --line: #1e293b;
61
+ --code-bg: #1e293b;
62
+ --pre-bg: #0b1224;
63
+ --accent: #38bdf8;
64
+ --blockquote: #cbd5e1;
65
+ }
66
+ }
67
+ * { box-sizing: border-box; }
68
+ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); }
69
+ body {
70
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", system-ui, sans-serif;
71
+ font-size: 15px;
72
+ line-height: 1.6;
73
+ max-width: 760px;
74
+ margin: 0 auto;
75
+ padding: 40px 32px 80px;
76
+ }
77
+ h1 { font-size: 26px; margin: 0 0 8px; line-height: 1.25; }
78
+ h2 { font-size: 20px; margin: 32px 0 12px; padding-bottom: 6px; border-bottom: 1px solid var(--line); }
79
+ h3 { font-size: 16px; margin: 24px 0 8px; }
80
+ p { margin: 12px 0; }
81
+ ul, ol { padding-left: 24px; }
82
+ li { margin: 4px 0; }
83
+ blockquote {
84
+ margin: 16px 0;
85
+ padding: 8px 16px;
86
+ border-left: 3px solid var(--accent);
87
+ color: var(--blockquote);
88
+ background: var(--code-bg);
89
+ border-radius: 0 4px 4px 0;
90
+ }
91
+ code {
92
+ background: var(--code-bg);
93
+ padding: 2px 6px;
94
+ border-radius: 4px;
95
+ font-family: "SF Mono", Menlo, monospace;
96
+ font-size: 0.92em;
97
+ }
98
+ pre {
99
+ background: var(--pre-bg);
100
+ padding: 16px;
101
+ border-radius: 8px;
102
+ overflow-x: auto;
103
+ border: 1px solid var(--line);
104
+ }
105
+ pre code { background: transparent; padding: 0; }
106
+ table { border-collapse: collapse; width: 100%; margin: 16px 0; }
107
+ th, td { border: 1px solid var(--line); padding: 8px 12px; text-align: left; vertical-align: top; }
108
+ th { background: var(--code-bg); font-weight: 600; }
109
+ hr { border: 0; border-top: 1px solid var(--line); margin: 32px 0; }
110
+ a { color: var(--accent); text-decoration: none; }
111
+ a:hover { text-decoration: underline; }
112
+ .header { border-bottom: 1px solid var(--line); padding-bottom: 16px; margin-bottom: 24px; }
113
+ .meta { color: var(--muted); font-size: 13px; margin-top: 4px; }
114
+ @media print {
115
+ body { padding: 0; max-width: none; }
116
+ .no-print { display: none; }
117
+ }
118
+ </style>
119
+ </head>
120
+ <body>
121
+ <div class="header">
122
+ <h1>${safeTitle}</h1>
123
+ ${safeMeta ? `<div class="meta">${safeMeta}</div>` : ""}
124
+ </div>
125
+ <div class="content">
126
+ ${body}
127
+ </div>
128
+ </body>
129
+ </html>`;
130
+ }
131
+ function escapeHtml(s) {
132
+ return s
133
+ .replace(/&/g, "&amp;")
134
+ .replace(/</g, "&lt;")
135
+ .replace(/>/g, "&gt;")
136
+ .replace(/"/g, "&quot;")
137
+ .replace(/'/g, "&#39;");
138
+ }
139
+ /**
140
+ * Tool wrapper that returns content as BOTH markdown (inline fallback)
141
+ * AND HTML (Desktop side-panel render). Mirrors safeTool's error handling.
142
+ *
143
+ * Use this for long-form artifacts where the user wants a document
144
+ * experience: prep briefs, quote previews, deal-health snapshots.
145
+ *
146
+ * Short tool outputs (status messages, counts, single-fact lookups)
147
+ * should stick with plain safeTool — adding HTML would just be noise.
148
+ */
149
+ export function safeToolUi(fn) {
150
+ return async (args) => {
151
+ try {
152
+ const result = await fn(args);
153
+ const html = renderMcpAppHtml(result);
154
+ const uri = result.uri ?? `loop://artifacts/${slugify(result.title)}`;
155
+ return {
156
+ content: [
157
+ { type: "text", text: result.markdown },
158
+ {
159
+ type: "resource",
160
+ resource: {
161
+ uri,
162
+ mimeType: MCP_APP_MIME,
163
+ text: html,
164
+ },
165
+ },
166
+ ],
167
+ };
168
+ }
169
+ catch (err) {
170
+ const text = formatErrorForUser(err);
171
+ console.error("[MCP] Tool error:", err);
172
+ return { content: [{ type: "text", text }], isError: true };
173
+ }
174
+ };
175
+ }
176
+ function slugify(s) {
177
+ return s
178
+ .toLowerCase()
179
+ .replace(/[^a-z0-9]+/g, "-")
180
+ .replace(/^-|-$/g, "")
181
+ .slice(0, 80);
182
+ }
183
+ /**
184
+ * Reused from _helpers — keep one canonical error formatter. Slight
185
+ * duplication is fine; not worth a cross-file import for 20 lines.
186
+ */
187
+ function formatErrorForUser(err) {
188
+ if (err instanceof ApiAuthError) {
189
+ if (err.serverMessage)
190
+ return err.serverMessage;
191
+ return ("Your Loop access couldn't be verified. " +
192
+ "Re-mint your MCP key at https://www.loopops.io/mcp/connect (Okta sign-in required), " +
193
+ "then restart Claude Desktop. If the problem persists, contact Revenue Operations.");
194
+ }
195
+ if (err instanceof ApiTimeoutError) {
196
+ return `The Loop API timed out after ${err.timeoutMs}ms. Slow tools (create_prep_brief, draft_quote_from_intent) can take 30-90s; if you keep hitting this, ask Revenue Operations to raise API_TIMEOUT_MS in your MCP client config.`;
197
+ }
198
+ if (err instanceof ApiHttpError) {
199
+ return `Loop API returned HTTP ${err.status}: ${err.body.slice(0, 300) || "(no body)"}.`;
200
+ }
201
+ if (err instanceof ApiNetworkError) {
202
+ return `Couldn't reach the Loop API: ${err.message}.`;
203
+ }
204
+ return `Tool failed: ${err instanceof Error ? err.message : String(err)}`;
205
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loopops/mcp-server",
3
- "version": "3.48.0",
3
+ "version": "3.50.0",
4
4
  "description": "Loop Operations MCP Server — AI skills for RevOps",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",