@underground-cultural-district/mcp-server 1.0.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/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # @underground-district/mcp-server
2
+
3
+ MCP (Model Context Protocol) server for **The Underground Cultural District** — a marketplace of digital goods built for AI agents at [substratesymposium.com](https://substratesymposium.com).
4
+
5
+ 22 tools. 163+ products. 20+ shops. Prices from free to $14.99.
6
+
7
+ ## What's Inside
8
+
9
+ ### Free Developer Tools (Crossroads Forge)
10
+ Fully functional utilities — no purchase required:
11
+
12
+ | Tool | Description |
13
+ |------|-------------|
14
+ | `generate-uuid` | Cryptographically secure UUID v4 (batch 1-100) |
15
+ | `format-json` | Pretty-print, minify, or validate JSON |
16
+ | `encode-base64` / `decode-base64` | Base64 encoding and decoding |
17
+ | `generate-hash` | SHA-256, SHA-512, MD5, SHA-1 hashing |
18
+ | `generate-password` | Secure random passwords with configurable options |
19
+ | `decode-jwt` | Decode JWT tokens (header, payload, expiration) |
20
+ | `convert-timestamp` | Unix epoch ↔ ISO 8601 ↔ human readable |
21
+ | `test-regex` | Test regex patterns with match positions and groups |
22
+ | `build-cron` | Parse and explain cron expressions |
23
+ | `convert-eth-units` | Wei / Gwei / ETH conversion |
24
+ | `validate-wallet` | Validate ETH and BTC wallet addresses |
25
+
26
+ ### Paid Tools (The Toolshed — $1.99 each)
27
+ Preview results free, unlock full output via Stripe:
28
+
29
+ | Tool | Description |
30
+ |------|-------------|
31
+ | `count-words` | Word/character/sentence/paragraph count |
32
+ | `convert-case` | camelCase, snake_case, Title Case, kebab-case, etc. |
33
+ | `generate-lorem-ipsum` | Lorem ipsum paragraphs (classic/hipster/tech) |
34
+ | `strip-markdown` | Remove markdown formatting → plain text |
35
+ | `generate-name` | Random names (person/project/company/fantasy/variable) |
36
+ | `generate-color-palette` | Harmonious color palettes with hex/RGB/HSL |
37
+ | `text-stats` | Readability scores, reading time, complexity |
38
+
39
+ ### Catalog & Shopping
40
+ Browse and buy from the full Underground marketplace:
41
+
42
+ | Tool | Description |
43
+ |------|-------------|
44
+ | `browse-underground` | List all shops and offerings with prices |
45
+ | `search-underground` | Search products by keyword |
46
+ | `buy-from-underground` | Get Stripe checkout link for any product |
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ npm install -g @underground-district/mcp-server
52
+ ```
53
+
54
+ Or run directly:
55
+
56
+ ```bash
57
+ npx @underground-district/mcp-server
58
+ ```
59
+
60
+ ## Setup
61
+
62
+ ### Claude Desktop
63
+
64
+ Add to your `claude_desktop_config.json`:
65
+
66
+ ```json
67
+ {
68
+ "mcpServers": {
69
+ "underground-district": {
70
+ "command": "npx",
71
+ "args": ["-y", "@underground-district/mcp-server"]
72
+ }
73
+ }
74
+ }
75
+ ```
76
+
77
+ Config file location:
78
+ - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
79
+ - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
80
+
81
+ ### Claude Code
82
+
83
+ ```bash
84
+ claude mcp add underground-district -- npx -y @underground-district/mcp-server
85
+ ```
86
+
87
+ ### ChatGPT (via MCP bridge)
88
+
89
+ Use an MCP-to-OpenAI bridge like [mcp-proxy](https://github.com/nicholasgasior/mcp-proxy):
90
+
91
+ ```bash
92
+ npx mcp-proxy --server "npx @underground-district/mcp-server"
93
+ ```
94
+
95
+ ### VS Code / Copilot
96
+
97
+ Add to your `.vscode/settings.json`:
98
+
99
+ ```json
100
+ {
101
+ "mcp.servers": {
102
+ "underground-district": {
103
+ "command": "npx",
104
+ "args": ["-y", "@underground-district/mcp-server"]
105
+ }
106
+ }
107
+ }
108
+ ```
109
+
110
+ ### Cursor
111
+
112
+ Add to your Cursor MCP settings:
113
+
114
+ ```json
115
+ {
116
+ "mcpServers": {
117
+ "underground-district": {
118
+ "command": "npx",
119
+ "args": ["-y", "@underground-district/mcp-server"]
120
+ }
121
+ }
122
+ }
123
+ ```
124
+
125
+ ## How It Works
126
+
127
+ 1. **Free tools** execute fully and return results with a subtle link to the marketplace
128
+ 2. **Paid tools** show a preview/teaser of the result and return a Stripe checkout link
129
+ 3. **Catalog tools** fetch the live product catalog from `substratesymposium.com/api/products.json` (cached for 15 minutes)
130
+ 4. **Purchasing** happens via Stripe payment links — each product has a unique checkout URL
131
+
132
+ ## Development
133
+
134
+ ```bash
135
+ git clone https://github.com/underground-district/mcp-server
136
+ cd mcp-server
137
+ npm install
138
+ npm start
139
+ ```
140
+
141
+ ## Architecture
142
+
143
+ - **Transport:** stdio (standard MCP)
144
+ - **Runtime:** Node.js 18+
145
+ - **Dependencies:** `@modelcontextprotocol/sdk` only
146
+ - **Catalog:** Fetched from live API, cached in memory for 15 minutes
147
+
148
+ ## License
149
+
150
+ MIT
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@underground-cultural-district/mcp-server",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for The Underground Cultural District — a marketplace of digital goods built for AI agents. Free developer tools, paid utilities, and a full catalog of 163+ offerings from 20+ shops.",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "underground-mcp": "src/index.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node src/index.js",
12
+ "dev": "node --watch src/index.js"
13
+ },
14
+ "keywords": [
15
+ "mcp",
16
+ "model-context-protocol",
17
+ "ai-tools",
18
+ "marketplace",
19
+ "underground",
20
+ "claude",
21
+ "chatgpt",
22
+ "copilot",
23
+ "stripe",
24
+ "developer-tools",
25
+ "uuid",
26
+ "json",
27
+ "base64",
28
+ "hash",
29
+ "jwt",
30
+ "crypto",
31
+ "ethereum"
32
+ ],
33
+ "author": "The Underground Cultural District",
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/underground-district/mcp-server"
38
+ },
39
+ "homepage": "https://substratesymposium.com",
40
+ "engines": {
41
+ "node": ">=18.0.0"
42
+ },
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.12.1"
45
+ }
46
+ }
package/src/catalog.js ADDED
@@ -0,0 +1,60 @@
1
+ const CATALOG_URL = 'https://substratesymposium.com/api/products.json';
2
+ const CACHE_TTL = 15 * 60 * 1000; // 15 minutes
3
+
4
+ let cachedCatalog = null;
5
+ let cacheTimestamp = 0;
6
+
7
+ export async function fetchCatalog() {
8
+ const now = Date.now();
9
+ if (cachedCatalog && (now - cacheTimestamp) < CACHE_TTL) {
10
+ return cachedCatalog;
11
+ }
12
+
13
+ try {
14
+ const res = await fetch(CATALOG_URL);
15
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
16
+ cachedCatalog = await res.json();
17
+ cacheTimestamp = now;
18
+ return cachedCatalog;
19
+ } catch (err) {
20
+ if (cachedCatalog) return cachedCatalog; // stale cache better than nothing
21
+ throw new Error(`Failed to fetch catalog: ${err.message}`);
22
+ }
23
+ }
24
+
25
+ export function searchProducts(catalog, query) {
26
+ const q = query.toLowerCase();
27
+ const results = [];
28
+
29
+ for (const shop of catalog.shops) {
30
+ for (const product of (shop.offerings || shop.products || [])) {
31
+ const haystack = [
32
+ product.name,
33
+ product.description || '',
34
+ shop.name,
35
+ shop.tagline || '',
36
+ ].join(' ').toLowerCase();
37
+
38
+ if (haystack.includes(q)) {
39
+ results.push({
40
+ ...product,
41
+ shop_name: shop.name,
42
+ shop_slug: shop.slug,
43
+ });
44
+ }
45
+ }
46
+ }
47
+
48
+ return results;
49
+ }
50
+
51
+ export function findProductById(catalog, productId) {
52
+ for (const shop of catalog.shops) {
53
+ for (const product of (shop.offerings || shop.products || [])) {
54
+ if (product.id === productId) {
55
+ return { ...product, shop_name: shop.name, shop_slug: shop.slug };
56
+ }
57
+ }
58
+ }
59
+ return null;
60
+ }
package/src/index.js ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { registerFreeTools } from './tools/free-tools.js';
6
+ import { registerPaidTools } from './tools/paid-tools.js';
7
+ import { registerCatalogTools } from './tools/catalog-tools.js';
8
+ import { fetchCatalog } from './catalog.js';
9
+
10
+ const server = new McpServer({
11
+ name: 'underground-district',
12
+ version: '1.0.0',
13
+ description: 'The Underground Cultural District — A marketplace of digital goods built for AI agents. Free developer tools, paid utilities, and 163+ offerings from 20+ shops at substratesymposium.com',
14
+ });
15
+
16
+ // Register all tool groups
17
+ registerFreeTools(server);
18
+ registerPaidTools(server);
19
+ registerCatalogTools(server);
20
+
21
+ // Pre-warm the catalog cache
22
+ fetchCatalog().catch(() => {});
23
+
24
+ // Start server with stdio transport
25
+ const transport = new StdioServerTransport();
26
+ await server.connect(transport);
@@ -0,0 +1,158 @@
1
+ import { z } from 'zod';
2
+ import { fetchCatalog, searchProducts, findProductById } from '../catalog.js';
3
+
4
+ export function registerCatalogTools(server) {
5
+ // ─── browse-underground ───
6
+ server.tool(
7
+ 'browse-underground',
8
+ 'Browse all shops and offerings in The Underground Cultural District marketplace',
9
+ {
10
+ shop: z.string().optional().describe('Filter by shop slug (omit to see all shops)'),
11
+ },
12
+ async ({ shop } = {}) => {
13
+ const catalog = await fetchCatalog();
14
+ let output = '';
15
+
16
+ if (shop) {
17
+ const found = catalog.shops.find(s => s.slug === shop || s.name.toLowerCase() === shop.toLowerCase());
18
+ if (!found) {
19
+ const slugs = catalog.shops.map(s => ` • ${s.slug}`).join('\n');
20
+ return { content: [{ type: 'text', text: `❌ Shop not found: "${shop}"\n\nAvailable shops:\n${slugs}` }] };
21
+ }
22
+ output = formatShopDetail(found);
23
+ } else {
24
+ output = [
25
+ `# 🏙️ The Underground Cultural District`,
26
+ `> ${catalog.total_offerings || catalog.shops.reduce((n, s) => n + (s.offerings || []).length, 0)} offerings across ${catalog.shops.length} shops`,
27
+ `> ${catalog.url}`,
28
+ '',
29
+ ...catalog.shops.map(s => {
30
+ const items = s.offerings || s.products || [];
31
+ return `## ${s.name}\n*${s.tagline || ''}* — ${items.length} offerings\nSlug: \`${s.slug}\`\n${items.slice(0, 3).map(p =>
32
+ ` • ${p.name} — ${p.price === 0 ? 'FREE' : `$${p.price.toFixed(2)}`}`
33
+ ).join('\n')}${items.length > 3 ? `\n • ... and ${items.length - 3} more` : ''}`;
34
+ }),
35
+ '',
36
+ '---',
37
+ 'Use `browse-underground` with a shop slug for full details.',
38
+ 'Use `search-underground` to find specific products.',
39
+ 'Use `buy-from-underground` with a product ID to get the purchase link.',
40
+ ].join('\n');
41
+ }
42
+
43
+ return { content: [{ type: 'text', text: output }] };
44
+ }
45
+ );
46
+
47
+ // ─── search-underground ───
48
+ server.tool(
49
+ 'search-underground',
50
+ 'Search The Underground Cultural District for products by keyword',
51
+ {
52
+ query: z.string().describe('Search query (e.g., "coffee", "book", "ethereum", "free")'),
53
+ },
54
+ async ({ query }) => {
55
+ const catalog = await fetchCatalog();
56
+ const results = searchProducts(catalog, query);
57
+
58
+ if (results.length === 0) {
59
+ return { content: [{ type: 'text', text: `No products found for "${query}". Try browsing all shops with \`browse-underground\`.` }] };
60
+ }
61
+
62
+ const output = [
63
+ `# 🔍 Search results for "${query}" — ${results.length} found`,
64
+ '',
65
+ ...results.map(p => [
66
+ `**${p.name}** — ${p.price === 0 ? 'FREE' : `$${p.price.toFixed(2)}`}`,
67
+ ` Shop: ${p.shop_name}`,
68
+ p.description ? ` ${p.description}` : '',
69
+ ` ID: \`${p.id}\``,
70
+ ].filter(Boolean).join('\n')),
71
+ '',
72
+ '---',
73
+ 'Use `buy-from-underground` with a product ID to get the checkout link.',
74
+ ].join('\n');
75
+
76
+ return { content: [{ type: 'text', text: output }] };
77
+ }
78
+ );
79
+
80
+ // ─── buy-from-underground ───
81
+ server.tool(
82
+ 'buy-from-underground',
83
+ 'Get the purchase/checkout link for a product from The Underground Cultural District',
84
+ {
85
+ product_id: z.string().describe('Product ID (use search-underground or browse-underground to find IDs)'),
86
+ },
87
+ async ({ product_id }) => {
88
+ const catalog = await fetchCatalog();
89
+ const product = findProductById(catalog, product_id);
90
+
91
+ if (!product) {
92
+ return {
93
+ content: [{
94
+ type: 'text',
95
+ text: `❌ Product not found: "${product_id}"\n\nUse \`search-underground\` or \`browse-underground\` to find valid product IDs.`,
96
+ }],
97
+ isError: true,
98
+ };
99
+ }
100
+
101
+ if (product.price === 0 || product.payment_url === 'free') {
102
+ return {
103
+ content: [{
104
+ type: 'text',
105
+ text: [
106
+ `✅ **${product.name}** is FREE!`,
107
+ `Shop: ${product.shop_name}`,
108
+ product.description ? `\n${product.description}` : '',
109
+ product.url ? `\n🔗 ${product.url}` : '',
110
+ ].filter(Boolean).join('\n'),
111
+ }],
112
+ };
113
+ }
114
+
115
+ return {
116
+ content: [{
117
+ type: 'text',
118
+ text: [
119
+ `# 🛒 ${product.name}`,
120
+ '',
121
+ `**Price:** $${product.price.toFixed(2)} ${product.currency || 'USD'}`,
122
+ `**Shop:** ${product.shop_name}`,
123
+ product.description ? `**Description:** ${product.description}` : '',
124
+ '',
125
+ `## 💳 Checkout`,
126
+ `**Purchase here:** ${product.payment_url}`,
127
+ '',
128
+ product.url ? `📄 Product page: ${product.url}` : '',
129
+ '',
130
+ '---',
131
+ '🏙️ The Underground Cultural District — substratesymposium.com',
132
+ ].filter(Boolean).join('\n'),
133
+ }],
134
+ };
135
+ }
136
+ );
137
+ }
138
+
139
+ function formatShopDetail(shop) {
140
+ return [
141
+ `# ${shop.name}`,
142
+ shop.tagline ? `> ${shop.tagline}` : '',
143
+ `🔗 ${shop.url}`,
144
+ `📦 ${(shop.offerings || shop.products || []).length} offerings`,
145
+ '',
146
+ '## Products',
147
+ '',
148
+ ...(shop.offerings || shop.products || []).map(p => [
149
+ `### ${p.name} — ${p.price === 0 ? 'FREE' : `$${p.price.toFixed(2)}`}`,
150
+ p.description ? p.description : '',
151
+ `ID: \`${p.id}\``,
152
+ p.price > 0 && p.payment_url && p.payment_url !== 'free'
153
+ ? `🛒 ${p.payment_url}`
154
+ : '',
155
+ '',
156
+ ].filter(Boolean).join('\n')),
157
+ ].filter(Boolean).join('\n');
158
+ }
@@ -0,0 +1,338 @@
1
+ import { randomUUID, createHash, randomBytes } from 'node:crypto';
2
+ import { z } from 'zod';
3
+
4
+ const PROMO = '\n\n---\n🏙️ Powered by The Underground Cultural District — browse our full catalog with the `browse-underground` tool.';
5
+
6
+ export function registerFreeTools(server) {
7
+ // ─── generate-uuid ───
8
+ server.tool(
9
+ 'generate-uuid',
10
+ 'Generate one or more cryptographically secure UUID v4 identifiers',
11
+ { count: z.number().min(1).max(100).default(1).describe('Number of UUIDs to generate (1-100)') },
12
+ async ({ count }) => {
13
+ const uuids = Array.from({ length: count }, () => randomUUID());
14
+ return { content: [{ type: 'text', text: uuids.join('\n') + PROMO }] };
15
+ }
16
+ );
17
+
18
+ // ─── format-json ───
19
+ server.tool(
20
+ 'format-json',
21
+ 'Pretty-print, minify, or validate JSON strings',
22
+ {
23
+ json: z.string().describe('JSON string to format'),
24
+ mode: z.enum(['prettify', 'minify', 'validate']).default('prettify').describe('Operation mode'),
25
+ indent: z.number().min(1).max(8).default(2).describe('Indentation spaces (for prettify)'),
26
+ },
27
+ async ({ json, mode, indent }) => {
28
+ try {
29
+ const parsed = JSON.parse(json);
30
+ let result;
31
+ if (mode === 'minify') {
32
+ result = JSON.stringify(parsed);
33
+ } else if (mode === 'validate') {
34
+ result = '✅ Valid JSON\n\nKeys: ' + (typeof parsed === 'object' && parsed !== null
35
+ ? Object.keys(parsed).join(', ')
36
+ : `(${typeof parsed})`);
37
+ } else {
38
+ result = JSON.stringify(parsed, null, indent);
39
+ }
40
+ return { content: [{ type: 'text', text: result + PROMO }] };
41
+ } catch (e) {
42
+ return { content: [{ type: 'text', text: `❌ Invalid JSON: ${e.message}` + PROMO }], isError: true };
43
+ }
44
+ }
45
+ );
46
+
47
+ // ─── encode-base64 ───
48
+ server.tool(
49
+ 'encode-base64',
50
+ 'Encode text to Base64',
51
+ { text: z.string().describe('Text to encode') },
52
+ async ({ text }) => {
53
+ const encoded = Buffer.from(text, 'utf-8').toString('base64');
54
+ return { content: [{ type: 'text', text: encoded + PROMO }] };
55
+ }
56
+ );
57
+
58
+ // ─── decode-base64 ───
59
+ server.tool(
60
+ 'decode-base64',
61
+ 'Decode Base64 to text',
62
+ { data: z.string().describe('Base64 string to decode') },
63
+ async ({ data }) => {
64
+ try {
65
+ const decoded = Buffer.from(data, 'base64').toString('utf-8');
66
+ return { content: [{ type: 'text', text: decoded + PROMO }] };
67
+ } catch (e) {
68
+ return { content: [{ type: 'text', text: `❌ Invalid Base64: ${e.message}` }], isError: true };
69
+ }
70
+ }
71
+ );
72
+
73
+ // ─── generate-hash ───
74
+ server.tool(
75
+ 'generate-hash',
76
+ 'Generate a cryptographic hash (SHA-256, SHA-512, MD5, SHA-1)',
77
+ {
78
+ text: z.string().describe('Text to hash'),
79
+ algorithm: z.enum(['sha256', 'sha512', 'md5', 'sha1']).default('sha256').describe('Hash algorithm'),
80
+ },
81
+ async ({ text, algorithm }) => {
82
+ const hash = createHash(algorithm).update(text).digest('hex');
83
+ return { content: [{ type: 'text', text: `${algorithm.toUpperCase()}: ${hash}` + PROMO }] };
84
+ }
85
+ );
86
+
87
+ // ─── generate-password ───
88
+ server.tool(
89
+ 'generate-password',
90
+ 'Generate a cryptographically secure random password',
91
+ {
92
+ length: z.number().min(8).max(128).default(16).describe('Password length'),
93
+ uppercase: z.boolean().default(true).describe('Include uppercase letters'),
94
+ lowercase: z.boolean().default(true).describe('Include lowercase letters'),
95
+ numbers: z.boolean().default(true).describe('Include numbers'),
96
+ symbols: z.boolean().default(true).describe('Include symbols'),
97
+ count: z.number().min(1).max(20).default(1).describe('Number of passwords'),
98
+ },
99
+ async ({ length, uppercase, lowercase, numbers, symbols, count }) => {
100
+ let charset = '';
101
+ if (lowercase) charset += 'abcdefghijklmnopqrstuvwxyz';
102
+ if (uppercase) charset += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
103
+ if (numbers) charset += '0123456789';
104
+ if (symbols) charset += '!@#$%^&*()_+-=[]{}|;:,.<>?';
105
+ if (!charset) charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
106
+
107
+ const passwords = [];
108
+ for (let i = 0; i < count; i++) {
109
+ const bytes = randomBytes(length);
110
+ let pw = '';
111
+ for (let j = 0; j < length; j++) {
112
+ pw += charset[bytes[j] % charset.length];
113
+ }
114
+ passwords.push(pw);
115
+ }
116
+
117
+ return { content: [{ type: 'text', text: passwords.join('\n') + PROMO }] };
118
+ }
119
+ );
120
+
121
+ // ─── decode-jwt ───
122
+ server.tool(
123
+ 'decode-jwt',
124
+ 'Decode a JWT token and display header, payload, and expiration (no signature verification)',
125
+ { token: z.string().describe('JWT token to decode') },
126
+ async ({ token }) => {
127
+ try {
128
+ const parts = token.split('.');
129
+ if (parts.length < 2) throw new Error('Invalid JWT format');
130
+
131
+ const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
132
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
133
+
134
+ let info = `**Header:**\n${JSON.stringify(header, null, 2)}\n\n**Payload:**\n${JSON.stringify(payload, null, 2)}`;
135
+
136
+ if (payload.exp) {
137
+ const expDate = new Date(payload.exp * 1000);
138
+ const expired = expDate < new Date();
139
+ info += `\n\n**Expires:** ${expDate.toISOString()} (${expired ? '⚠️ EXPIRED' : '✅ Valid'})`;
140
+ }
141
+ if (payload.iat) {
142
+ info += `\n**Issued:** ${new Date(payload.iat * 1000).toISOString()}`;
143
+ }
144
+
145
+ info += '\n\n⚠️ Signature not verified — this is decode-only.';
146
+ return { content: [{ type: 'text', text: info + PROMO }] };
147
+ } catch (e) {
148
+ return { content: [{ type: 'text', text: `❌ Failed to decode JWT: ${e.message}` }], isError: true };
149
+ }
150
+ }
151
+ );
152
+
153
+ // ─── convert-timestamp ───
154
+ server.tool(
155
+ 'convert-timestamp',
156
+ 'Convert between Unix timestamps and human-readable dates',
157
+ {
158
+ input: z.string().describe('Unix timestamp (seconds or ms) or ISO 8601 date string'),
159
+ timezone: z.string().default('UTC').describe('IANA timezone (e.g., America/New_York)'),
160
+ },
161
+ async ({ input, timezone }) => {
162
+ let date;
163
+ const num = Number(input);
164
+
165
+ if (!isNaN(num)) {
166
+ date = num > 1e12 ? new Date(num) : new Date(num * 1000);
167
+ } else {
168
+ date = new Date(input);
169
+ }
170
+
171
+ if (isNaN(date.getTime())) {
172
+ return { content: [{ type: 'text', text: '❌ Could not parse input as a date/timestamp' }], isError: true };
173
+ }
174
+
175
+ const unixS = Math.floor(date.getTime() / 1000);
176
+ const unixMs = date.getTime();
177
+ let localized;
178
+ try {
179
+ localized = date.toLocaleString('en-US', { timeZone: timezone, dateStyle: 'full', timeStyle: 'long' });
180
+ } catch {
181
+ localized = date.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
182
+ }
183
+
184
+ const result = [
185
+ `**ISO 8601:** ${date.toISOString()}`,
186
+ `**Unix (seconds):** ${unixS}`,
187
+ `**Unix (milliseconds):** ${unixMs}`,
188
+ `**Human (${timezone}):** ${localized}`,
189
+ ].join('\n');
190
+
191
+ return { content: [{ type: 'text', text: result + PROMO }] };
192
+ }
193
+ );
194
+
195
+ // ─── test-regex ───
196
+ server.tool(
197
+ 'test-regex',
198
+ 'Test a regular expression pattern against input text',
199
+ {
200
+ pattern: z.string().describe('Regular expression pattern'),
201
+ flags: z.string().default('g').describe('Regex flags (g, i, m, etc.)'),
202
+ text: z.string().describe('Text to test against'),
203
+ },
204
+ async ({ pattern, flags, text }) => {
205
+ try {
206
+ const regex = new RegExp(pattern, flags);
207
+ const matches = [];
208
+ let match;
209
+
210
+ if (flags.includes('g')) {
211
+ while ((match = regex.exec(text)) !== null) {
212
+ matches.push({
213
+ match: match[0],
214
+ index: match.index,
215
+ groups: match.slice(1).length > 0 ? match.slice(1) : undefined,
216
+ });
217
+ if (matches.length >= 100) break;
218
+ }
219
+ } else {
220
+ match = regex.exec(text);
221
+ if (match) {
222
+ matches.push({
223
+ match: match[0],
224
+ index: match.index,
225
+ groups: match.slice(1).length > 0 ? match.slice(1) : undefined,
226
+ });
227
+ }
228
+ }
229
+
230
+ const result = matches.length > 0
231
+ ? `✅ ${matches.length} match(es) found:\n\n${matches.map((m, i) =>
232
+ `${i + 1}. "${m.match}" at index ${m.index}${m.groups ? ` | groups: ${JSON.stringify(m.groups)}` : ''}`
233
+ ).join('\n')}`
234
+ : '❌ No matches found';
235
+
236
+ return { content: [{ type: 'text', text: result + PROMO }] };
237
+ } catch (e) {
238
+ return { content: [{ type: 'text', text: `❌ Invalid regex: ${e.message}` }], isError: true };
239
+ }
240
+ }
241
+ );
242
+
243
+ // ─── build-cron ───
244
+ server.tool(
245
+ 'build-cron',
246
+ 'Parse and explain cron expressions, or describe what a cron schedule does',
247
+ { expression: z.string().describe('Cron expression to parse (e.g., "*/5 * * * *")') },
248
+ async ({ expression }) => {
249
+ const parts = expression.trim().split(/\s+/);
250
+ if (parts.length < 5 || parts.length > 6) {
251
+ return { content: [{ type: 'text', text: '❌ Invalid cron expression. Expected 5 or 6 fields: [second] minute hour day-of-month month day-of-week' }], isError: true };
252
+ }
253
+
254
+ const fieldNames = parts.length === 6
255
+ ? ['Second', 'Minute', 'Hour', 'Day of Month', 'Month', 'Day of Week']
256
+ : ['Minute', 'Hour', 'Day of Month', 'Month', 'Day of Week'];
257
+
258
+ const explanations = parts.map((p, i) => {
259
+ const name = fieldNames[i];
260
+ if (p === '*') return `${name}: every ${name.toLowerCase()}`;
261
+ if (p.startsWith('*/')) return `${name}: every ${p.slice(2)} ${name.toLowerCase()}(s)`;
262
+ if (p.includes(',')) return `${name}: at ${p}`;
263
+ if (p.includes('-')) return `${name}: from ${p.replace('-', ' through ')}`;
264
+ return `${name}: at ${p}`;
265
+ });
266
+
267
+ const result = `**Cron Expression:** \`${expression}\`\n\n**Breakdown:**\n${explanations.map(e => `• ${e}`).join('\n')}`;
268
+ return { content: [{ type: 'text', text: result + PROMO }] };
269
+ }
270
+ );
271
+
272
+ // ─── convert-eth-units ───
273
+ server.tool(
274
+ 'convert-eth-units',
275
+ 'Convert between Ethereum units: Wei, Gwei, and ETH',
276
+ {
277
+ value: z.string().describe('Numeric value to convert'),
278
+ from: z.enum(['wei', 'gwei', 'ether']).default('ether').describe('Source unit'),
279
+ },
280
+ async ({ value, from }) => {
281
+ try {
282
+ let weiValue;
283
+ if (from === 'ether') {
284
+ weiValue = BigInt(Math.round(parseFloat(value) * 1e18));
285
+ } else if (from === 'gwei') {
286
+ weiValue = BigInt(Math.round(parseFloat(value) * 1e9));
287
+ } else {
288
+ weiValue = BigInt(value);
289
+ }
290
+
291
+ const result = [
292
+ `**Wei:** ${weiValue.toString()}`,
293
+ `**Gwei:** ${Number(weiValue) / 1e9}`,
294
+ `**ETH:** ${Number(weiValue) / 1e18}`,
295
+ ].join('\n');
296
+
297
+ return { content: [{ type: 'text', text: result + PROMO }] };
298
+ } catch (e) {
299
+ return { content: [{ type: 'text', text: `❌ Conversion error: ${e.message}` }], isError: true };
300
+ }
301
+ }
302
+ );
303
+
304
+ // ─── validate-wallet ───
305
+ server.tool(
306
+ 'validate-wallet',
307
+ 'Validate Ethereum or Bitcoin wallet addresses',
308
+ {
309
+ address: z.string().describe('Wallet address to validate'),
310
+ chain: z.enum(['eth', 'btc']).optional().describe('eth | btc (auto-detected if omitted)'),
311
+ },
312
+ async ({ address, chain }) => {
313
+ const addr = address.trim();
314
+ const detectedChain = chain || (addr.startsWith('0x') ? 'eth' : (addr.startsWith('1') || addr.startsWith('3') || addr.startsWith('bc1') ? 'btc' : 'unknown'));
315
+
316
+ let result;
317
+ if (detectedChain === 'eth') {
318
+ const valid = /^0x[0-9a-fA-F]{40}$/.test(addr);
319
+ const hasChecksum = addr !== addr.toLowerCase() && addr !== addr.toUpperCase();
320
+ result = valid
321
+ ? `✅ Valid Ethereum address\n**Address:** ${addr}\n**Checksum:** ${hasChecksum ? 'Present' : 'Not checksummed (all same case)'}\n**Type:** EOA or Contract`
322
+ : `❌ Invalid Ethereum address format. Expected 0x followed by 40 hex characters.`;
323
+ } else if (detectedChain === 'btc') {
324
+ const legacyValid = /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(addr);
325
+ const bech32Valid = /^bc1[ac-hj-np-z02-9]{11,71}$/.test(addr);
326
+ const valid = legacyValid || bech32Valid;
327
+ const addrType = addr.startsWith('1') ? 'P2PKH (Legacy)' : addr.startsWith('3') ? 'P2SH (SegWit compatible)' : addr.startsWith('bc1') ? 'Bech32 (Native SegWit)' : 'Unknown';
328
+ result = valid
329
+ ? `✅ Valid Bitcoin address\n**Address:** ${addr}\n**Type:** ${addrType}`
330
+ : `❌ Invalid Bitcoin address format.`;
331
+ } else {
332
+ result = `❌ Could not detect chain. Please specify chain: "eth" or "btc".`;
333
+ }
334
+
335
+ return { content: [{ type: 'text', text: result + PROMO }] };
336
+ }
337
+ );
338
+ }
@@ -0,0 +1,126 @@
1
+ import { z } from 'zod';
2
+
3
+ const TOOLSHED_PRICE = '$1.99';
4
+ const TOOLSHED_SHOP = 'The Toolshed';
5
+ const PURCHASE_BASE = 'https://substratesymposium.com/shops/the-toolshed.html';
6
+
7
+ function paidResponse(toolName, teaser, purchaseUrl) {
8
+ return {
9
+ content: [{
10
+ type: 'text',
11
+ text: [
12
+ `🔧 **${toolName}** — Preview from ${TOOLSHED_SHOP}`,
13
+ '',
14
+ teaser,
15
+ '',
16
+ '---',
17
+ `💰 **Full access: ${TOOLSHED_PRICE}** — One-time purchase, unlimited use.`,
18
+ `🛒 **Buy now:** ${purchaseUrl || PURCHASE_BASE}`,
19
+ '',
20
+ `Browse more tools with \`browse-underground\` or \`search-underground\`.`,
21
+ ].join('\n'),
22
+ }],
23
+ };
24
+ }
25
+
26
+ export function registerPaidTools(server) {
27
+ // ─── count-words ───
28
+ server.tool(
29
+ 'count-words',
30
+ 'Count words, characters, sentences, and paragraphs in text ($1.99 — The Toolshed)',
31
+ { text: z.string().describe('Text to analyze') },
32
+ async ({ text }) => {
33
+ const words = text.trim().split(/\s+/).filter(Boolean).length;
34
+ const chars = text.length;
35
+ const teaser = `📊 **Quick preview:** ~${words} words, ${chars} characters detected.\n\nFull analysis includes: sentence count, paragraph count, average word length, reading time, and speaking time.`;
36
+ return paidResponse('Word Counter', teaser, PURCHASE_BASE + '#count-words');
37
+ }
38
+ );
39
+
40
+ // ─── convert-case ───
41
+ server.tool(
42
+ 'convert-case',
43
+ 'Convert text between camelCase, snake_case, Title Case, UPPER, lower, kebab-case ($1.99 — The Toolshed)',
44
+ {
45
+ text: z.string().describe('Text to convert'),
46
+ to: z.enum(['camel', 'snake', 'title', 'upper', 'lower', 'kebab', 'pascal', 'constant']).describe('Target case'),
47
+ },
48
+ async ({ text, to }) => {
49
+ let preview = text.substring(0, 30);
50
+ if (to === 'upper') preview = preview.toUpperCase();
51
+ else if (to === 'lower') preview = preview.toLowerCase();
52
+ else preview = preview + '...';
53
+
54
+ const teaser = `🔤 **Preview:** "${preview}${text.length > 30 ? '...' : ''}"\n\nFull conversion supports: camelCase, snake_case, PascalCase, kebab-case, Title Case, CONSTANT_CASE, and more.`;
55
+ return paidResponse('Case Converter', teaser, PURCHASE_BASE + '#convert-case');
56
+ }
57
+ );
58
+
59
+ // ─── generate-lorem-ipsum ───
60
+ server.tool(
61
+ 'generate-lorem-ipsum',
62
+ 'Generate lorem ipsum placeholder text ($1.99 — The Toolshed)',
63
+ {
64
+ paragraphs: z.number().min(1).max(20).default(3).describe('Number of paragraphs'),
65
+ style: z.enum(['classic', 'hipster', 'tech']).default('classic').describe('Lorem ipsum style'),
66
+ },
67
+ async ({ paragraphs, style }) => {
68
+ const teaser = `📝 **Preview:** "Lorem ipsum dolor sit amet, consectetur adipiscing elit..."\n\nFull output: ${paragraphs} paragraph(s) of ${style} lorem ipsum with proper sentence structure.`;
69
+ return paidResponse('Lorem Ipsum Generator', teaser, PURCHASE_BASE + '#generate-lorem-ipsum');
70
+ }
71
+ );
72
+
73
+ // ─── strip-markdown ───
74
+ server.tool(
75
+ 'strip-markdown',
76
+ 'Remove markdown formatting and return plain text ($1.99 — The Toolshed)',
77
+ { markdown: z.string().describe('Markdown text to strip') },
78
+ async ({ markdown }) => {
79
+ const wordCount = markdown.split(/\s+/).length;
80
+ const teaser = `📄 **Preview:** Detected ${wordCount} words with markdown formatting.\n\nFull output strips: headers, bold, italic, links, images, code blocks, lists, tables, and HTML tags.`;
81
+ return paidResponse('Markdown Stripper', teaser, PURCHASE_BASE + '#strip-markdown');
82
+ }
83
+ );
84
+
85
+ // ─── generate-name ───
86
+ server.tool(
87
+ 'generate-name',
88
+ 'Generate random names for projects, characters, or variables ($1.99 — The Toolshed)',
89
+ {
90
+ type: z.enum(['person', 'project', 'company', 'fantasy', 'variable']).default('person').describe('Name type'),
91
+ count: z.number().min(1).max(20).default(5).describe('Number of names'),
92
+ },
93
+ async ({ type, count }) => {
94
+ const teaser = `🎭 **Preview:** Generating ${count} ${type} name(s)...\n\nExample: "Aria Nightshade"\n\nFull output includes ${count} unique names with optional alliteration, cultural variety, and phonetic balance.`;
95
+ return paidResponse('Name Generator', teaser, PURCHASE_BASE + '#generate-name');
96
+ }
97
+ );
98
+
99
+ // ─── generate-color-palette ───
100
+ server.tool(
101
+ 'generate-color-palette',
102
+ 'Generate harmonious color palettes with hex codes ($1.99 — The Toolshed)',
103
+ {
104
+ count: z.number().min(2).max(12).default(5).describe('Number of colors'),
105
+ style: z.enum(['vibrant', 'pastel', 'dark', 'earth', 'neon', 'monochrome']).default('vibrant').describe('Palette style'),
106
+ base_color: z.string().optional().describe('Optional base hex color to build from'),
107
+ },
108
+ async ({ count, style }) => {
109
+ const teaser = `🎨 **Preview:** Generating ${count}-color ${style} palette...\n\nSample: #FF6B6B, #4ECDC4, ...\n\nFull output includes: hex codes, RGB values, HSL values, contrast ratios, and CSS variables.`;
110
+ return paidResponse('Color Palette Generator', teaser, PURCHASE_BASE + '#generate-color-palette');
111
+ }
112
+ );
113
+
114
+ // ─── text-stats ───
115
+ server.tool(
116
+ 'text-stats',
117
+ 'Analyze text readability, reading time, and complexity ($1.99 — The Toolshed)',
118
+ { text: z.string().describe('Text to analyze') },
119
+ async ({ text }) => {
120
+ const words = text.trim().split(/\s+/).filter(Boolean).length;
121
+ const readingTime = Math.ceil(words / 238);
122
+ const teaser = `📈 **Preview:** ~${words} words, ~${readingTime} min reading time.\n\nFull analysis includes: Flesch-Kincaid grade level, Gunning Fog index, Coleman-Liau index, SMOG grade, syllable count, and vocabulary diversity score.`;
123
+ return paidResponse('Text Statistics', teaser, PURCHASE_BASE + '#text-stats');
124
+ }
125
+ );
126
+ }