• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

thoughtspot / mcp-server / 16209157863

11 Jul 2025 12:46AM CUT coverage: 89.668% (+1.2%) from 88.438%
16209157863

push

github

web-flow
Honeycomb traces (#34)

* Integrate with honeycomb otel library to get traces from MCP server in honeycomb

* use decorator patter using annotations to add tracing

* update error message

146 of 171 branches covered (85.38%)

Branch coverage included in aggregate %.

346 of 378 new or added lines in 8 files covered. (91.53%)

1 existing line in 1 file now uncovered.

557 of 613 relevant lines covered (90.86%)

102.38 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.38
/src/oauth-manager/oauth-utils.ts
1
import type { ClientInfo, AuthRequest } from '@cloudflare/workers-oauth-provider'
2
import { encodeBase64Url } from 'hono/utils/encode'
3

4

5
/**
6
 * Configuration for the approval dialog
7
 */
8
export interface ApprovalDialogOptions {
9
  /**
10
   * Client information to display in the approval dialog
11
   */
12
  client: ClientInfo | null
13
  /**
14
   * Server information to display in the approval dialog
15
   */
16
  server: {
17
    name: string
18
    logo?: string
19
    description?: string
20
  }
21
  /**
22
   * Arbitrary state data to pass through the approval flow
23
   * Will be encoded in the form and returned when approval is complete
24
   */
25
  state: Record<string, any>
26
  /**
27
   * Name of the cookie to use for storing approvals
28
   * @default "mcp_approved_clients"
29
   */
30
  cookieName?: string
31
  /**
32
   * Secret used to sign cookies for verification
33
   * Can be a string or Uint8Array
34
   * @default Built-in Uint8Array key
35
   */
36
  cookieSecret?: string | Uint8Array
37
  /**
38
   * Cookie domain
39
   * @default current domain
40
   */
41
  cookieDomain?: string
42
  /**
43
   * Cookie path
44
   * @default "/"
45
   */
46
  cookiePath?: string
47
  /**
48
   * Cookie max age in seconds
49
   * @default 30 days
50
   */
51
  cookieMaxAge?: number
52
}
53

54
/**
55
 * Renders an approval dialog for OAuth authorization
56
 * The dialog displays information about the client and server
57
 * and includes a form to submit approval
58
 *
59
 * @param request - The HTTP request
60
 * @param options - Configuration for the approval dialog
61
 * @returns A Response containing the HTML approval dialog
62
 */
63
export function renderApprovalDialog(request: Request, options: ApprovalDialogOptions): Response {
64
    const { server, state } = options;
31✔
65
    const encodedState = btoa(JSON.stringify(state));
31✔
66
    const serverName = sanitizeHtml(server.name);
31✔
67
    const mcpLogoUrl = 'https://raw.githubusercontent.com/thoughtspot/mcp-server/refs/heads/main/static/MCP%20Server%20Logo.svg';
31✔
68
    const thoughtspotLogoUrl = 'https://avatars.githubusercontent.com/u/8906680?s=200&v=4';
31✔
69

70
    const htmlContent = `
31✔
71
      <!DOCTYPE html>
72
      <html lang="en">
73
        <head>
74
          <meta charset="UTF-8">
75
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
76
          <title>${serverName} | Authorization Request</title>
77
          <style>
78
            html, body {
79
              height: 100%;
80
              margin: 0;
81
              padding: 0;
82
              background: #f6f7fa;
83
            }
84
            body {
85
              min-height: 100vh;
86
              display: flex;
87
              align-items: center;
88
              justify-content: center;
89
              font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
90
              color: #111827;
91
            }
92
            .approval-card {
93
              background: #fff;
94
              border-radius: 18px;
95
              box-shadow: 0 2px 16px 0 rgba(16,30,54,0.10), 0 1.5px 4px 0 rgba(16,30,54,0.06);
96
              max-width: 520px;
97
              width: 100%;
98
              padding: 40px 32px 32px 32px;
99
              box-sizing: border-box;
100
              display: flex;
101
              flex-direction: column;
102
              align-items: center;
103
            }
104
            .approval-logos {
105
              display: flex;
106
              align-items: center;
107
              justify-content: center;
108
              margin-bottom: 32px;
109
              gap: 40px;
110
            }
111
            .approval-logo {
112
              width: 64px;
113
              height: 64px;
114
              object-fit: contain;
115
              background: #fff;
116
              border-radius: 12px;
117
              box-shadow: 0 2px 8px rgba(0,0,0,0.04);
118
            }
119
            .approval-arrow {
120
              width: 56px;
121
              height: 32px;
122
              display: flex;
123
              align-items: center;
124
              justify-content: center;
125
            }
126
            .approval-title {
127
              font-size: 1.25rem;
128
              font-weight: 700;
129
              text-align: center;
130
              margin: 0 0 32px 0;
131
              line-height: 1.3;
132
            }
133
            .approval-form {
134
              width: 100%;
135
              display: flex;
136
              flex-direction: column;
137
              align-items: stretch;
138
            }
139
            .form-group {
140
              margin-bottom: 28px;
141
            }
142
            .form-group label {
143
              display: block;
144
              margin-bottom: 8px;
145
              font-weight: 400;
146
              font-size: 0.94rem;
147
              color: #111827;
148
              transition: color 0.2s;
149
            }
150
            .form-group label.label-blue {
151
              color: #2563eb;
152
            }
153
            .form-group label.label-red {
154
              color: #dc2626;
155
            }
156
            .form-group input {
157
              width: 100%;
158
              padding: 14px 16px;
159
              border: 1.5px solid #d1d5db;
160
              border-radius: 8px;
161
              font-size: 0.94rem;
162
              background: #fff;
163
              box-sizing: border-box;
164
              transition: border-color 0.2s;
165
            }
166
            .form-group input.input-blue {
167
              border-color: #2563eb;
168
            }
169
            .form-group input.input-red {
170
              border-color: #dc2626;
171
            }
172
            .approval-subtitle {
173
              font-weight: 600;
174
              font-size: 0.94rem;
175
              margin-bottom: 8px;
176
              margin-top: 0;
177
            }
178
            .approval-permissions {
179
              margin: 0 0 32px 0;
180
              padding: 0 0 0 18px;
181
              list-style: disc;
182
              color: #111827;
183
              font-size: 0.94rem;
184
            }
185
            .approval-permissions li {
186
              margin-bottom: 8px;
187
              line-height: 1.6;
188
            }
189
            .approval-actions {
190
              display: flex;
191
              justify-content: space-between;
192
              gap: 16px;
193
              margin-bottom: 18px;
194
            }
195
            .terms-checkbox {
196
              margin-bottom: 24px;
197
              display: flex;
198
              align-items: flex-start;
199
              gap: 8px;
200
            }
201
            .terms-checkbox input[type="checkbox"] {
202
              margin-top: 3px;
203
            }
204
            .terms-checkbox label {
205
              font-size: 0.94rem;
206
              line-height: 1.4;
207
              color: #111827;
208
            }
209
            .terms-checkbox a {
210
              color: #2563eb;
211
              text-decoration: none;
212
            }
213
            .terms-checkbox a:hover {
214
              text-decoration: underline;
215
            }
216
            .button {
217
              flex: 1 1 0;
218
              padding: 12px 0;
219
              border-radius: 8px;
220
              font-weight: 500;
221
              font-size: 0.94rem;
222
              border: none;
223
              cursor: pointer;
224
              transition: background 0.2s, color 0.2s;
225
            }
226
            .button-cancel {
227
              background: #f3f4f6;
228
              color: #6b7280;
229
              border: none;
230
            }
231
            .button-cancel:hover {
232
              background: #e5e7eb;
233
            }
234
            .button-allow {
235
              background: #2563eb;
236
              color: #fff;
237
              border: none;
238
            }
239
            .button-allow:hover {
240
              background: #1a56db;
241
            }
242
            .button-allow:disabled {
243
              background: #93c5fd;
244
              cursor: not-allowed;
245
            }
246
            .approval-footer {
247
              text-align: center;
248
              font-size: 0.88rem;
249
              color: #111827;
250
              margin-top: 8px;
251
            }
252
            .approval-footer a {
253
              color: #2563eb;
254
              text-decoration: none;
255
              margin-left: 0.25em;
256
              font-weight: 500;
257
            }
258
            .approval-footer a:hover {
259
              text-decoration: underline;
260
            }
261
            @media (max-width: 600px) {
262
              .approval-card {
263
                padding: 18px 4vw 18px 4vw;
264
                max-width: 98vw;
265
              }
266
              .approval-logos {
267
                gap: 18px;
268
                margin-bottom: 18px;
269
              }
270
              .approval-title {
271
                font-size: 1.1rem;
272
                margin-bottom: 18px;
273
              }
274
            }
275
          </style>
276
        </head>
277
        <body>
278
          <div class="approval-card">
279
            <div class="approval-logos">
280
              <img src="${mcpLogoUrl}" alt="MCP Server Logo" class="approval-logo">
281
              <span class="approval-arrow">
282
                <svg width="56" height="32" viewBox="0 0 56 32" fill="none" xmlns="http://www.w3.org/2000/svg">
283
                  <g opacity="0.25">
284
                    <!-- Right arrow -->
285
                    <line x1="8" y1="10" x2="48" y2="10" stroke="#6B7280" stroke-width="2.5" stroke-linecap="round"/>
286
                    <polyline points="44,6 48,10 44,14" fill="none" stroke="#6B7280" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
287
                    <!-- Left arrow -->
288
                    <line x1="48" y1="22" x2="8" y2="22" stroke="#6B7280" stroke-width="2.5" stroke-linecap="round"/>
289
                    <polyline points="12,18 8,22 12,26" fill="none" stroke="#6B7280" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
290
                  </g>
291
                </svg>
292
              </span>
293
              <img src="${thoughtspotLogoUrl}" alt="ThoughtSpot Logo" class="approval-logo">
294
            </div>
295
            <div class="approval-title">ThoughtSpot MCP Server wants access<br>to your ThoughtSpot instance</div>
296
            <form class="approval-form" method="post" action="${new URL(request.url).pathname}" id="approvalForm" autocomplete="off" novalidate>
297
              <div class="form-group">
298
                <label for="instanceUrl" id="instanceUrlLabel">ThoughtSpot Instance URL</label>
299
                <input type="text" id="instanceUrl" name="instanceUrl" placeholder="https://your-instance.thoughtspot.cloud" autocomplete="off">
300
                <input type="hidden" name="state" value="${encodedState}">
301
              </div>
302
              <div class="approval-subtitle">ThoughtSpot MCP Server will be able to:</div>
303
              <ul class="approval-permissions">
304
                <li>Read all ThoughtSpot data you have access to</li>
305
                <li>Read all ThoughtSpot content you have access to</li>
306
                <li>Send data to the client you are connecting to</li>
307
              </ul>
308
              <div class="terms-checkbox">
309
                <input type="checkbox" id="termsCheckbox" name="termsCheckbox" required>
310
                <label for="termsCheckbox">
311
                  By checking this box, I acknowledge and agree that my use of this application is subject to the ThoughtSpot
312
                  <a href="https://www.thoughtspot.com/legal/thoughtspot-for-apps" target="_blank" rel="noopener noreferrer">Terms of Use</a> 
313
                  and <a href="https://www.thoughtspot.com/privacy-statement" target="_blank" rel="noopener noreferrer">Privacy Statement</a>.
314
                </label>
315
              </div>
316
              <div class="approval-actions">
317
                <button type="button" class="button button-cancel" onclick="window.history.back()">Cancel</button>
318
                <button type="submit" class="button button-allow" id="allowButton" disabled>Allow</button>
319
              </div>
320
            </form>
321
            <div class="approval-footer">
322
              Don't have an account?
323
              <a href="https://www.thoughtspot.com/trial" target="_blank" rel="noopener noreferrer">Sign up</a>
324
            </div>
325
          </div>
326
          <script>
327
            const input = document.getElementById('instanceUrl');
328
            const label = document.getElementById('instanceUrlLabel');
329
            const form = document.getElementById('approvalForm');
330
            const termsCheckbox = document.getElementById('termsCheckbox');
331
            const allowButton = document.getElementById('allowButton');
332
            let lastError = false;
333

334
            function setBlue() {
335
              input.classList.add('input-blue');
336
              input.classList.remove('input-red');
337
              label.classList.add('label-blue');
338
              label.classList.remove('label-red');
339
              label.textContent = 'ThoughtSpot Instance URL';
340
              lastError = false;
341
            }
342
            function setRed() {
343
              input.classList.add('input-red');
344
              input.classList.remove('input-blue');
345
              label.classList.add('label-red');
346
              label.classList.remove('label-blue');
347
              label.textContent = 'ThoughtSpot Instance URL';
348
              lastError = true;
349
            }
350
            function clearColors() {
351
              input.classList.remove('input-blue', 'input-red');
352
              label.classList.remove('label-blue', 'label-red');
353
              label.textContent = 'ThoughtSpot Instance URL';
354
              lastError = false;
355
            }
356
            function updateAllowButton() {
357
              allowButton.disabled = !(input.value.trim() && termsCheckbox.checked);
358
            }
359
            input.addEventListener('input', function() {
360
              if (input.value.trim()) {
361
                setBlue();
362
              } else {
363
                clearColors();
364
              }
365
              updateAllowButton();
366
            });
367
            termsCheckbox.addEventListener('change', updateAllowButton);
368
            form.addEventListener('submit', function(e) {
369
              if (!input.value.trim()) {
370
                e.preventDefault();
371
                setRed();
372
                input.focus();
373
              } else if (!termsCheckbox.checked) {
374
                e.preventDefault();
375
                termsCheckbox.focus();
376
              } else {
377
                setBlue();
378
              }
379
            });
380
          </script>
381
        </body>
382
      </html>
383
    `;
384
    return new Response(htmlContent, {
31✔
385
      headers: {
386
        'Content-Type': 'text/html; charset=utf-8',
387
      },
388
    });
389
}
390

391
/**
392
 * Decodes a base64-encoded state string back into an object
393
 */
394
function decodeState<T>(encodedState: string): T {
395
  try {
110✔
396
    const decoded = atob(encodedState);
110✔
397
    return JSON.parse(decoded) as T;
91✔
398
  } catch (e) {
399
    console.error('Error decoding state:', e);
19✔
400
    throw new Error('Invalid state format');
19✔
401
  }
402
}
403

404
/**
405
 * Result of parsing the approval form submission.
406
 */
407
export interface ParsedApprovalResult {
408
  /** The original state object passed through the form. */
409
  state: any
410
  /** The instance URL extracted from the form. */
411
  instanceUrl: string
412
}
413

414

415
/**
416
 * Validates and sanitizes a URL to ensure it's a valid ThoughtSpot instance URL
417
 * @param url - The URL to validate and sanitize
418
 * @returns The sanitized URL
419
 * @throws Error if the URL is invalid
420
 */
421
export function validateAndSanitizeUrl(url: string): string {
422
  try {
286✔
423
    // Remove any whitespace
424
    const trimmedUrl = url.trim();
286✔
425

426
    // Add https:// if no protocol is specified
427
    const urlWithProtocol = trimmedUrl.startsWith('http://') || trimmedUrl.startsWith('https://')
286✔
428
      ? trimmedUrl
429
      : `https://${trimmedUrl}`;
430

431
    const parsedUrl = new URL(urlWithProtocol);
286✔
432

433
    // Remove trailing slashes and normalize the URL
434
    const sanitizedUrl = parsedUrl.origin;
266✔
435

436
    return sanitizedUrl;
266✔
437
  } catch (e) {
438
    if (e instanceof Error) {
20!
439
      throw new Error(`Invalid URL: ${e.message}`);
20✔
440
    }
441
    throw new Error('Invalid URL format');
×
442
  }
443
}
444

445
/**
446
 * Parses the form submission from the approval dialog, extracts the state,
447
 * and generates Set-Cookie headers to mark the client as approved.
448
 *
449
 * @param request - The incoming POST Request object containing the form data.
450
 * @returns A promise resolving to an object containing the parsed state and necessary headers.
451
 * @throws If the request method is not POST, form data is invalid, or state is missing.
452
 */
453
export async function parseRedirectApproval(request: Request): Promise<ParsedApprovalResult> {
454
  if (request.method !== 'POST') {
148✔
455
    throw new Error('Invalid request method. Expected POST.')
4✔
456
  }
457

458
  let state: any
459
  let clientId: string | undefined
460
  let instanceUrl: string | undefined
461
  try {
144✔
462
    const formData = await request.formData()
144✔
463
    const encodedState = formData.get('state')
114✔
464
    const rawInstanceUrl = formData.get('instanceUrl') as string;
114✔
465

466
    if (typeof encodedState !== 'string' || !encodedState) {
114✔
467
      throw new Error("Missing or invalid 'state' in form data.")
4✔
468
    }
469

470
    state = decodeState<{ oauthReqInfo?: AuthRequest }>(encodedState)
110✔
471
    clientId = state?.oauthReqInfo?.clientId
91✔
472

473
    if (!clientId) {
91✔
474
      throw new Error('Could not extract clientId from state object.')
45✔
475
    }
476

477
    if (!rawInstanceUrl) {
46✔
478
      throw new Error('Missing instance URL')
34✔
479
    }
480

481
    // Validate and sanitize the instance URL
482
    instanceUrl = validateAndSanitizeUrl(rawInstanceUrl);
12✔
483
  } catch (e) {
484
    console.error('Error processing form submission:', e)
136✔
485
    throw new Error(`Failed to parse approval form: ${e instanceof Error ? e.message : String(e)}`)
136!
486
  }
487

488
  return { state, instanceUrl }
8✔
489
}
490

491
/**
492
 * Sanitizes HTML content to prevent XSS attacks
493
 * @param unsafe - The unsafe string that might contain HTML
494
 * @returns A safe string with HTML special characters escaped
495
 */
496
function sanitizeHtml(unsafe: string): string {
497
  return unsafe.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
31✔
498
}
499

500
/**
501
 * Constructs the SAML login redirect URL for the /authorize POST handler.
502
 * @param instanceUrl The instance URL to use as the base for the redirect.
503
 * @param oauthReqInfo The OAuth request info object to encode in the state.
504
 * @param callbackOrigin The origin to use for the callback URL (e.g., from the incoming request).
505
 * @returns The full redirect URL as a string.
506
 */
507
export function buildSamlRedirectUrl(instanceUrl: string, oauthReqInfo: any, callbackOrigin: string): string {
508
    // Construct the redirect URL to v1/saml
509
    const redirectUrl = new URL('callosum/v1/saml/login', instanceUrl);
16✔
510
    const targetURLPath = new URL("/callback", callbackOrigin);
16✔
511
    targetURLPath.searchParams.append('instanceUrl', instanceUrl);
16✔
512
    const encodedState = encodeBase64Url(new TextEncoder().encode(JSON.stringify(oauthReqInfo)).buffer);
16✔
513
    targetURLPath.searchParams.append('oauthReqInfo', encodedState);
16✔
514
    redirectUrl.searchParams.append('targetURLPath', targetURLPath.href);
16✔
515
    return redirectUrl.toString();
16✔
516
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc