thumbgate 1.23.2 → 1.25.1

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.
@@ -1,10 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
+ // Human-readable display title from a snake_case tool name.
5
+ // e.g. "capture_feedback" -> "Capture Feedback". The Claude Connectors Directory
6
+ // requires every tool to carry BOTH a title and a readOnlyHint/destructiveHint.
7
+ function humanizeTitle(name) {
8
+ return String(name || '')
9
+ .split(/[_-]+/)
10
+ .filter(Boolean)
11
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
12
+ .join(' ');
13
+ }
14
+
4
15
  function readOnlyTool(tool) {
5
16
  return {
6
17
  ...tool,
18
+ title: tool.title || humanizeTitle(tool.name),
7
19
  annotations: {
20
+ title: tool.title || humanizeTitle(tool.name),
8
21
  readOnlyHint: true,
9
22
  },
10
23
  };
@@ -13,7 +26,9 @@ function readOnlyTool(tool) {
13
26
  function destructiveTool(tool) {
14
27
  return {
15
28
  ...tool,
29
+ title: tool.title || humanizeTitle(tool.name),
16
30
  annotations: {
31
+ title: tool.title || humanizeTitle(tool.name),
17
32
  destructiveHint: true,
18
33
  },
19
34
  };
@@ -1396,6 +1411,25 @@ const TOOLS = [
1396
1411
  }),
1397
1412
  ];
1398
1413
 
1414
+ // Normalize at export: guarantee EVERY tool carries a human-readable title and a
1415
+ // readOnlyHint/destructiveHint annotation (both required by the Connectors
1416
+ // Directory; the #1 rejection cause is missing annotations). Tools defined as
1417
+ // plain objects (not via readOnlyTool/destructiveTool) are backfilled here:
1418
+ // title from the name, and a conservative destructiveHint when no hint is set
1419
+ // (so an un-hinted tool is gated rather than silently treated as read-only).
1420
+ const NORMALIZED_TOOLS = TOOLS.map((tool) => {
1421
+ const title = tool.title || humanizeTitle(tool.name);
1422
+ const existing = tool.annotations || {};
1423
+ const hasHint = existing.readOnlyHint === true || existing.destructiveHint === true;
1424
+ const annotations = {
1425
+ title,
1426
+ ...existing,
1427
+ ...(hasHint ? {} : { destructiveHint: true }),
1428
+ };
1429
+ return { ...tool, title, annotations };
1430
+ });
1431
+
1399
1432
  module.exports = {
1400
- TOOLS,
1433
+ TOOLS: NORMALIZED_TOOLS,
1434
+ humanizeTitle,
1401
1435
  };
@@ -312,6 +312,7 @@ function setGeminiEmbedderForTests(loader) {
312
312
  module.exports = {
313
313
  upsertFeedback,
314
314
  searchSimilar,
315
+ embed,
315
316
  TABLE_NAME,
316
317
  getEmbeddingConfig,
317
318
  getLastEmbeddingProfile,
package/src/api/server.js CHANGED
@@ -199,6 +199,10 @@ const {
199
199
  } = require('../../scripts/rate-limiter');
200
200
  const { sendProblem, PROBLEM_TYPES } = require('../../scripts/problem-detail');
201
201
  const { TOOLS: MCP_TOOLS } = require('../../scripts/tool-registry');
202
+ const mcpOauth = require('../../scripts/mcp-oauth');
203
+ // OAuth 2.1 (PKCE) authorization-server state for the remote MCP connector
204
+ // (Claude Connectors Directory requires OAuth for authenticated services).
205
+ const oauthStore = mcpOauth.createStore();
202
206
  const resendMailer = require('../../scripts/mailer/resend-mailer');
203
207
  const {
204
208
  buildContextFootprintReport,
@@ -748,16 +752,23 @@ function updateLessonRecord(feedbackDir, lessonId, updater) {
748
752
  function getPublicMcpTools() {
749
753
  return MCP_TOOLS.map((tool) => ({
750
754
  name: tool.name,
755
+ ...(tool.title ? { title: tool.title } : {}),
751
756
  description: tool.description,
752
757
  inputSchema: tool.inputSchema,
758
+ // Serve the tool-registry annotations (readOnlyHint/destructiveHint). Required
759
+ // by the Claude Connectors Directory (missing annotations = the #1 rejection
760
+ // cause) and used by MCP clients for permission prompts. Was being dropped here.
761
+ ...(tool.annotations ? { annotations: tool.annotations } : {}),
753
762
  }));
754
763
  }
755
764
 
756
765
  function getServerCardTools() {
757
766
  return MCP_TOOLS.map((tool) => ({
758
767
  name: tool.name,
768
+ ...(tool.title ? { title: tool.title } : {}),
759
769
  description: tool.description,
760
770
  inputSchema: tool.inputSchema,
771
+ ...(tool.annotations ? { annotations: tool.annotations } : {}),
761
772
  }));
762
773
  }
763
774
 
@@ -3860,12 +3871,56 @@ function createApiServer() {
3860
3871
  tools: getPublicMcpTools(),
3861
3872
  },
3862
3873
  });
3874
+ } else if (msg.method === 'tools/call') {
3875
+ // Authenticated tool execution. Accept either an OAuth 2.1 access
3876
+ // token (audience-bound to this MCP server, RFC 8707) or a raw
3877
+ // ThumbGate API key, both via the Bearer header.
3878
+ const bearer = extractBearerToken(req);
3879
+ const resourceUrl = buildPublicUrl(hostedConfig, '/mcp');
3880
+ const oauthSession = mcpOauth.resolveAccessToken(oauthStore, bearer);
3881
+ // OAuth path: token must resolve AND be audience-bound to this server
3882
+ // (RFC 8707). Raw-key path: only an exact match to a configured
3883
+ // operator/admin key — never "any non-empty bearer".
3884
+ const adminKey = String(process.env.THUMBGATE_API_KEY || '').trim();
3885
+ const operatorKey = String(process.env.THUMBGATE_OPERATOR_KEY || '').trim();
3886
+ const rawKeyValid = Boolean(bearer) && ((adminKey && bearer === adminKey) || (operatorKey && bearer === operatorKey));
3887
+ const authed = oauthSession
3888
+ ? mcpOauth.tokenAudienceValid(oauthSession, resourceUrl)
3889
+ : rawKeyValid;
3890
+ if (!authed) {
3891
+ res.writeHead(401, {
3892
+ 'Content-Type': 'application/json',
3893
+ // RFC 9728: point unauthenticated clients at the resource metadata.
3894
+ 'WWW-Authenticate': `Bearer resource_metadata="${buildPublicUrl(hostedConfig, '/.well-known/oauth-protected-resource')}"`,
3895
+ });
3896
+ res.end(JSON.stringify({
3897
+ jsonrpc: '2.0', id: msg.id,
3898
+ error: { code: -32001, message: 'Authentication required. Use OAuth 2.1 (see /.well-known/oauth-protected-resource) or a ThumbGate API key.' },
3899
+ }));
3900
+ return;
3901
+ }
3902
+ (async () => {
3903
+ try {
3904
+ const { callTool } = require('../../adapters/mcp/server-stdio');
3905
+ const name = msg.params && msg.params.name;
3906
+ const args = (msg.params && msg.params.arguments) || {};
3907
+ const result = await callTool(name, args);
3908
+ sendJson(res, 200, {
3909
+ jsonrpc: '2.0', id: msg.id,
3910
+ result: { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] },
3911
+ });
3912
+ } catch (err) {
3913
+ sendJson(res, 200, {
3914
+ jsonrpc: '2.0', id: msg.id,
3915
+ result: { isError: true, content: [{ type: 'text', text: String(err && err.message || err) }] },
3916
+ });
3917
+ }
3918
+ })();
3863
3919
  } else {
3864
- // All other tool calls require auth — return method not found for unauthenticated
3865
3920
  sendJson(res, 200, {
3866
3921
  jsonrpc: '2.0',
3867
3922
  id: msg.id,
3868
- error: { code: -32601, message: 'Method requires authentication. Provide Bearer token.' },
3923
+ error: { code: -32601, message: `Method not found: ${msg.method}` },
3869
3924
  });
3870
3925
  }
3871
3926
  } catch (_e) {
@@ -5166,6 +5221,108 @@ async function addContext(){
5166
5221
  return;
5167
5222
  }
5168
5223
 
5224
+ // OAuth 2.1 discovery metadata for the remote MCP connector (RFC 9728 / RFC 8414).
5225
+ // Lets Claude discover how to authenticate before calling authenticated tools.
5226
+ if (isGetLikeRequest && pathname === '/.well-known/oauth-protected-resource') {
5227
+ sendJson(res, 200, mcpOauth.buildProtectedResourceMetadata(buildPublicUrl(hostedConfig, '')), {}, {
5228
+ headOnly: isHeadRequest,
5229
+ });
5230
+ return;
5231
+ }
5232
+ if (isGetLikeRequest && (pathname === '/.well-known/oauth-authorization-server' || pathname === '/.well-known/openid-configuration')) {
5233
+ sendJson(res, 200, mcpOauth.buildAuthServerMetadata(buildPublicUrl(hostedConfig, '')), {}, {
5234
+ headOnly: isHeadRequest,
5235
+ });
5236
+ return;
5237
+ }
5238
+
5239
+ // --- OAuth 2.1 (PKCE) endpoints for the remote MCP connector ---
5240
+ // RFC 7591 Dynamic Client Registration.
5241
+ if (req.method === 'POST' && pathname === '/oauth/register') {
5242
+ let body = '';
5243
+ req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
5244
+ req.on('end', () => {
5245
+ let parsed = {};
5246
+ try { parsed = body ? JSON.parse(body) : {}; } catch { /* ignore */ }
5247
+ const reg = mcpOauth.registerClient(oauthStore, parsed);
5248
+ if (reg.error) { sendJson(res, 400, reg); return; }
5249
+ sendJson(res, 201, reg);
5250
+ });
5251
+ return;
5252
+ }
5253
+ // Authorization endpoint: GET renders consent, POST issues the code.
5254
+ if (pathname === '/oauth/authorize') {
5255
+ if (isGetLikeRequest) {
5256
+ const q = parsed.searchParams;
5257
+ const fields = ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'resource'];
5258
+ const hidden = fields.map((f) => `<input type="hidden" name="${f}" value="${escapeHtmlAttribute(q.get(f) || '')}">`).join('\n');
5259
+ const html = `<!doctype html><html><head><meta charset="utf-8"><title>Authorize ThumbGate</title>
5260
+ <style>body{font:15px system-ui;margin:0;background:#0b0b0c;color:#eee;display:flex;min-height:100vh;align-items:center;justify-content:center}
5261
+ .card{background:#161618;border:1px solid #2a2a2e;border-radius:12px;padding:28px;max-width:420px}
5262
+ input[type=password]{width:100%;padding:10px;margin:8px 0 16px;border-radius:8px;border:1px solid #2a2a2e;background:#0b0b0c;color:#eee}
5263
+ button{width:100%;padding:11px;border-radius:8px;border:0;background:#10b981;color:#04120c;font-weight:600;cursor:pointer}
5264
+ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oauth/authorize">
5265
+ <h2>Authorize Claude → ThumbGate</h2>
5266
+ <p>Paste your ThumbGate API key to let this connector act as you. Get one with <code>npx thumbgate init</code> or from your <a href="/dashboard">dashboard</a>.</p>
5267
+ ${hidden}
5268
+ <input type="password" name="api_key" placeholder="ThumbGate API key" autocomplete="off" required>
5269
+ <button type="submit" name="approve" value="yes">Approve</button>
5270
+ </form></body></html>`;
5271
+ sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5272
+ return;
5273
+ }
5274
+ if (req.method === 'POST') {
5275
+ let body = '';
5276
+ req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
5277
+ req.on('end', () => {
5278
+ const form = new URLSearchParams(body);
5279
+ const redirectUri = form.get('redirect_uri') || '';
5280
+ const state = form.get('state') || '';
5281
+ const issued = mcpOauth.createAuthorizationCode(oauthStore, {
5282
+ clientId: form.get('client_id') || '',
5283
+ redirectUri,
5284
+ codeChallenge: form.get('code_challenge') || '',
5285
+ codeChallengeMethod: form.get('code_challenge_method') || '',
5286
+ scope: form.get('scope') || undefined,
5287
+ resource: form.get('resource') || buildPublicUrl(hostedConfig, '/mcp'),
5288
+ boundKey: form.get('api_key') || '',
5289
+ state,
5290
+ });
5291
+ if (issued.error) {
5292
+ sendJson(res, 400, { error: issued.error, error_description: issued.error_description });
5293
+ return;
5294
+ }
5295
+ const sep = redirectUri.includes('?') ? '&' : '?';
5296
+ const loc = `${redirectUri}${sep}code=${encodeURIComponent(issued.code)}${state ? `&state=${encodeURIComponent(state)}` : ''}`;
5297
+ res.writeHead(302, { Location: loc });
5298
+ res.end();
5299
+ });
5300
+ return;
5301
+ }
5302
+ }
5303
+ // Token endpoint (authorization_code + PKCE).
5304
+ if (req.method === 'POST' && pathname === '/oauth/token') {
5305
+ let body = '';
5306
+ req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
5307
+ req.on('end', () => {
5308
+ const form = new URLSearchParams(body);
5309
+ if (form.get('grant_type') !== 'authorization_code') {
5310
+ sendJson(res, 400, { error: 'unsupported_grant_type' });
5311
+ return;
5312
+ }
5313
+ const tok = mcpOauth.exchangeCode(oauthStore, {
5314
+ code: form.get('code') || '',
5315
+ codeVerifier: form.get('code_verifier') || '',
5316
+ clientId: form.get('client_id') || '',
5317
+ redirectUri: form.get('redirect_uri') || '',
5318
+ resource: form.get('resource') || undefined,
5319
+ });
5320
+ if (tok.error) { sendJson(res, 400, tok); return; }
5321
+ sendJson(res, 200, tok, { 'Cache-Control': 'no-store' });
5322
+ });
5323
+ return;
5324
+ }
5325
+
5169
5326
  if (isGetLikeRequest && pathname === '/.well-known/mcp/tools.json') {
5170
5327
  sendJson(res, 200, {
5171
5328
  name: 'thumbgate',
@@ -6240,6 +6397,49 @@ async function addContext(){
6240
6397
  }
6241
6398
 
6242
6399
 
6400
+ // Remote MCP connector documentation — the `resource_documentation` target
6401
+ // advertised by /.well-known/oauth-protected-resource. The Claude Connectors
6402
+ // Directory requires this URL to resolve (200) for submission review.
6403
+ if (isGetLikeRequest && (pathname === '/docs/connectors' || pathname === '/docs/connectors/')) {
6404
+ const mcpUrl = buildPublicUrl(hostedConfig, '/mcp');
6405
+ const cardUrl = buildPublicUrl(hostedConfig, '/.well-known/mcp/server-card.json');
6406
+ const prmUrl = buildPublicUrl(hostedConfig, '/.well-known/oauth-protected-resource');
6407
+ sendHtml(res, 200, `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Remote MCP Connector — ThumbGate</title><meta name="description" content="Connect ThumbGate to Claude as a remote MCP server: OAuth 2.1 (PKCE) authorization, available tools, and the read-only reviewer credential."><style>body{font-family:system-ui,-apple-system,sans-serif;max-width:780px;margin:0 auto;padding:32px 20px;line-height:1.55;color:#1f2937}h1{font-size:30px;margin:0 0 8px}.lede{color:#6b7280;font-size:18px;margin:0 0 28px}h2{font-size:20px;margin:28px 0 8px}code{background:#f3f4f6;padding:1px 6px;border-radius:4px;font-size:13.5px}pre{background:#0f172a;color:#e2e8f0;padding:14px 16px;border-radius:8px;overflow:auto;font-size:13px}a{color:#0066cc}ol,ul{padding-left:22px}li{margin:6px 0}.note{border-left:3px solid #22d3ee;background:#ecfeff;padding:10px 14px;border-radius:0 8px 8px 0;margin:16px 0}footer{margin-top:40px;padding-top:20px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:14px}</style></head><body>
6408
+ <h1>ThumbGate — Remote MCP Connector</h1>
6409
+ <p class="lede">ThumbGate is a remote Model Context Protocol (MCP) server. Add it as a connector in Claude to give an agent governed access to ThumbGate's feedback capture, lesson retrieval, context assembly, and pre-action gate tools — over HTTP, authenticated with OAuth 2.1.</p>
6410
+
6411
+ <h2>Connect URL</h2>
6412
+ <pre>${esc(mcpUrl)}</pre>
6413
+ <p>In Claude, add a custom connector and paste the URL above. Claude discovers the authorization server automatically via the protected-resource metadata at <a href="${esc(prmUrl)}">${esc(prmUrl)}</a>.</p>
6414
+
6415
+ <h2>Authorization (OAuth 2.1 + PKCE)</h2>
6416
+ <ol>
6417
+ <li>Claude reads the <code>resource</code> and <code>authorization_servers</code> from the protected-resource metadata.</li>
6418
+ <li>It runs the standard OAuth 2.1 authorization-code flow with PKCE (<code>S256</code> only — <code>plain</code> is rejected).</li>
6419
+ <li>The issued access token is audience-bound to the <code>${esc(mcpUrl)}</code> resource (RFC 8707); a token for any other audience is rejected.</li>
6420
+ <li>The bearer token is then sent on the <code>Authorization</code> header for every <code>tools/call</code>.</li>
6421
+ </ol>
6422
+
6423
+ <h2>Available tools</h2>
6424
+ <p>The full, machine-readable tool registry — names, input schemas, and the <code>readOnlyHint</code> / <code>destructiveHint</code> annotations — is published at <a href="${esc(cardUrl)}">${esc(cardUrl)}</a>. Tools fall into these groups:</p>
6425
+ <ul>
6426
+ <li><strong>Feedback &amp; lessons</strong> — <code>capture_feedback</code>, <code>feedback_summary</code>, <code>search_lessons</code>, <code>retrieve_lessons</code>, <code>prevention_rules</code>.</li>
6427
+ <li><strong>Context engineering</strong> — <code>construct_context_pack</code>, <code>evaluate_context_pack</code>, <code>unified_context</code>, <code>recall</code>.</li>
6428
+ <li><strong>Pre-action gates &amp; governance</strong> — <code>satisfy_gate</code>, <code>track_action</code>, <code>approve_protected_action</code>, <code>verify_claim</code>, <code>enforcement_matrix</code>.</li>
6429
+ <li><strong>Diagnostics &amp; planning</strong> — <code>diagnose_failure</code>, <code>suggest_fix</code>, <code>security_scan</code>, and the <code>plan_*</code> advisory tools.</li>
6430
+ </ul>
6431
+
6432
+ <h2>Reviewer credential (read-only)</h2>
6433
+ <div class="note">For directory reviewers: ThumbGate issues a dedicated <strong>read-only</strong> reviewer credential. A token bound to that credential may invoke only tools annotated <code>readOnlyHint: true</code>; any write or mutating tool call is rejected. This makes the credential safe to share for review without granting the ability to mutate shared server state. Request it from the contact below.</div>
6434
+
6435
+ <h2>Source &amp; contact</h2>
6436
+ <p>Open-source CLI and server: <a href="https://github.com/IgorGanapolsky/ThumbGate">github.com/IgorGanapolsky/ThumbGate</a>. Questions or reviewer-credential requests: <a href="mailto:igor.ganapolsky@gmail.com">igor.ganapolsky@gmail.com</a>.</p>
6437
+
6438
+ <footer><a href="/">ThumbGate</a> · <a href="/support">Support</a> · <a href="/privacy">Privacy</a> · <a href="/terms">Terms</a></footer>
6439
+ </body></html>`, {}, { headOnly: isHeadRequest });
6440
+ return;
6441
+ }
6442
+
6243
6443
  // Public support / contact page — required for Stripe Business → Public
6244
6444
  // details "Customer support URL" field. Single source of truth for how
6245
6445
  // customers reach us (email, GitHub issues, status page).
@@ -8072,6 +8272,8 @@ module.exports = {
8072
8272
  renderPackagedLessonsHtml,
8073
8273
  readOptionalPublicTemplate,
8074
8274
  resolveLocalPageBootstrap,
8275
+ getPublicMcpTools,
8276
+ getServerCardTools,
8075
8277
  },
8076
8278
  };
8077
8279