thumbgate 1.23.2 → 1.25.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.
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',
@@ -8072,6 +8229,8 @@ module.exports = {
8072
8229
  renderPackagedLessonsHtml,
8073
8230
  readOptionalPublicTemplate,
8074
8231
  resolveLocalPageBootstrap,
8232
+ getPublicMcpTools,
8233
+ getServerCardTools,
8075
8234
  },
8076
8235
  };
8077
8236