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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +15 -4
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/package.json +16 -4
- package/public/ai-malpractice-prevention.html +62 -5
- package/public/index.html +2 -2
- package/public/numbers.html +2 -2
- package/scripts/context-manager.js +22 -4
- package/scripts/gates-engine.js +98 -13
- package/scripts/mcp-oauth.js +293 -0
- package/scripts/security-scanner.js +80 -10
- package/scripts/thompson-sampling.js +16 -10
- package/scripts/tool-registry.js +35 -1
- package/scripts/vector-store.js +1 -0
- package/src/api/server.js +204 -2
package/scripts/tool-registry.js
CHANGED
|
@@ -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
|
};
|
package/scripts/vector-store.js
CHANGED
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:
|
|
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 & 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 & 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 & 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 & 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
|
|