From a52d32c3674635c67661bbe512f98cfee77e4c58 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Sat, 7 Feb 2026 00:49:40 +1000 Subject: [PATCH] feat: add resend tools, MCP collection endpoint, and UI refinements - Add @tpmjs/tools-resend with email API tools and blocks.yml entries - Add MCP route for collection skill discovery - Add InstallationSection component for collections - Refactor collection pages to use shared components and simplify layouts - Update skills questions API, rate limiting, and API key handling - Add tpmjs-tool-creator skill for Claude - Update video feature scenes and fix lint issues - Update .gitignore with IDE and temp file exclusions --- .claude/skills/tpmjs-tool-creator/SKILL.md | 290 ++++++ .../tpmjs-tool-creator/references/domain.md | 58 ++ .gitignore | 12 + apps/web/next.config.ts | 4 +- .../[slug]/CollectionDetailClient.tsx | 228 +---- .../collections/[slug]/mcp/route.ts | 359 +++++++ .../[username]/collections/[slug]/page.tsx | 136 ++- .../skills/questions/QuestionsListClient.tsx | 20 +- .../[questionId]/QuestionDetailClient.tsx | 21 +- .../skills/questions/[questionId]/page.tsx | 4 +- .../app/api/skills/questions/[id]/route.ts | 44 +- apps/web/src/app/collections/[id]/page.tsx | 392 +------- apps/web/src/app/collections/page.tsx | 300 +----- .../app/dashboard/collections/[id]/page.tsx | 134 +-- .../app/dashboard/likes/collections/page.tsx | 2 +- apps/web/src/components/AppHeader.tsx | 5 - .../collections/InstallationSection.tsx | 301 ++++++ .../components/skills/SkillsActivityFeed.tsx | 9 +- .../web/src/components/skills/SkillsStats.tsx | 8 +- apps/web/src/lib/api-keys/index.ts | 6 +- apps/web/src/lib/rate-limit.ts | 12 +- apps/web/src/lib/rate-limiter.ts | 2 +- packages/cli/oclif.manifest.json | 820 ++++++---------- packages/tools/official/blocks.yml | 550 +++++++++++ packages/tools/official/resend/block.ts | 66 ++ packages/tools/official/resend/index.ts | 2 + packages/tools/official/resend/package.json | 166 ++++ packages/tools/official/resend/src/index.ts | 875 ++++++++++++++++++ packages/tools/official/resend/tsconfig.json | 11 + packages/tools/official/resend/tsup.config.ts | 10 + .../features/CollectionsFeatureScene.tsx | 15 +- .../src/scenes/features/CustomAgentsScene.tsx | 13 +- .../src/scenes/features/DeveloperSDKScene.tsx | 16 +- .../src/scenes/features/LivingSkillsScene.tsx | 13 +- .../src/scenes/features/MCPProtocolScene.tsx | 18 +- .../src/scenes/features/OmegaAgentScene.tsx | 23 +- .../scenes/features/SecureExecutionScene.tsx | 17 +- .../scenes/features/TestScenariosScene.tsx | 10 +- .../src/scenes/features/ToolRegistryScene.tsx | 16 +- pnpm-lock.yaml | 16 + 40 files changed, 3310 insertions(+), 1694 deletions(-) create mode 100644 .claude/skills/tpmjs-tool-creator/SKILL.md create mode 100644 .claude/skills/tpmjs-tool-creator/references/domain.md create mode 100644 apps/web/src/app/(profile)/[username]/collections/[slug]/mcp/route.ts create mode 100644 apps/web/src/components/collections/InstallationSection.tsx create mode 100644 packages/tools/official/resend/block.ts create mode 100644 packages/tools/official/resend/index.ts create mode 100644 packages/tools/official/resend/package.json create mode 100644 packages/tools/official/resend/src/index.ts create mode 100644 packages/tools/official/resend/tsconfig.json create mode 100644 packages/tools/official/resend/tsup.config.ts diff --git a/.claude/skills/tpmjs-tool-creator/SKILL.md b/.claude/skills/tpmjs-tool-creator/SKILL.md new file mode 100644 index 0000000..590bc67 --- /dev/null +++ b/.claude/skills/tpmjs-tool-creator/SKILL.md @@ -0,0 +1,290 @@ +--- +name: tpmjs-tool-creator +description: Guide for creating official TPMJS tools using the blocks CLI. Use when a user wants to create a new tool for the TPMJS registry, add a tool to packages/tools/official/, implement an AI SDK v6 tool, define a block in blocks.yml, validate a tool with `pnpm blocks run`, or publish a tool to npm with the tpmjs keyword. +--- + +# TPMJS Tool Creator + +Create production-ready tools for the TPMJS registry using the blocks CLI. Tools are npm packages following the AI SDK v6 pattern, validated by blocks, and automatically synced to tpmjs.com. + +## Workflow + +1. Define the tool block in `packages/tools/official/blocks.yml` +2. Create the tool package directory +3. Implement the tool using AI SDK v6 `tool()` + `jsonSchema()` +4. Validate with `pnpm blocks run ` +5. Build and publish to npm + +## Step 1: Define in blocks.yml + +Add to the `blocks:` section of `packages/tools/official/blocks.yml`: + +```yaml +blocks: + category.toolName: + type: utility + description: "LLM-friendly description of what the tool does" + path: "tool-directory-name" + domain_rules: + - id: rule_name + description: "What this implementation must do" + inputs: + - name: inputName + type: string + description: "Description for LLMs" + - name: optionalInput + type: number + optional: true + description: "Optional parameter" + outputs: + - name: result + type: ResultType + description: "What the tool returns" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] +``` + +**Category prefix** (before the dot): `research`, `web`, `data`, `documentation`, `engineering`, `security`, `statistics`, `ops`, `agent`, `sandbox`, `utilities`, `html`, `compliance`. + +For domain entities and quality measures, see [references/domain.md](references/domain.md). + +## Step 2: Create Package Directory + +Create `packages/tools/official//`: + +``` +/ +├── package.json +├── tsconfig.json +├── tsup.config.ts +├── README.md +└── src/ + └── index.ts +``` + +**package.json:** +```json +{ + "name": "@tpmjs/official-", + "version": "0.1.0", + "description": "Short description", + "type": "module", + "keywords": ["tpmjs", "", "ai"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "type-check": "tsc --noEmit", + "clean": "rm -rf dist .turbo" + }, + "devDependencies": { + "@tpmjs/tsconfig": "workspace:*", + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "dependencies": { + "ai": "6.0.49" + }, + "publishConfig": { "access": "public" }, + "repository": { + "type": "git", + "url": "https://github.com/tpmjs/tpmjs.git", + "directory": "packages/tools/official/" + }, + "homepage": "https://tpmjs.com", + "license": "MIT", + "tpmjs": { + "category": "", + "frameworks": ["vercel-ai"], + "tools": [ + { + "name": "toolName", + "description": "Clear description (20+ chars)." + } + ] + } +} +``` + +**tsconfig.json:** +```json +{ + "extends": "@tpmjs/tsconfig/react-library.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} +``` + +**tsup.config.ts:** +```typescript +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + clean: true, + sourcemap: true, + target: 'es2022', +}); +``` + +## Step 3: Implement the Tool + +Every tool follows this AI SDK v6 pattern in `src/index.ts`: + +```typescript +import { jsonSchema, tool } from 'ai'; + +interface MyToolInput { + param1: string; + param2?: number; +} + +export interface MyToolResult { + data: string; + metadata: { processedAt: string }; +} + +export const myTool = tool({ + description: 'Clear LLM-friendly description of what this tool does.', + parameters: jsonSchema({ + type: 'object', + properties: { + param1: { + type: 'string', + description: 'What param1 is for', + }, + param2: { + type: 'number', + description: 'Optional: what param2 is for', + }, + }, + required: ['param1'], + additionalProperties: false, + }), + execute: async (input): Promise => { + if (!input.param1) { + throw new Error('param1 is required and must be non-empty'); + } + + try { + const result = await processData(input.param1); + return { + data: result, + metadata: { processedAt: new Date().toISOString() }, + }; + } catch (error) { + throw new Error( + `Failed to process: ${error instanceof Error ? error.message : String(error)}` + ); + } + }, +}); + +export default myTool; +``` + +**Hard rules:** +- No stubs, TODOs, or placeholders — every tool must be fully working +- Single-shot: one call in, one structured result out +- Validate inputs before processing +- Try-catch with descriptive errors including context +- `additionalProperties: false` on jsonSchema +- Description on every schema property +- Export as both named and default export +- Output interface must be exported + +### Multi-Tool Packages + +For packages with multiple tools, add root-level files: + +**block.ts:** +```typescript +import { toolA, toolB } from './src/index.js'; +export const block = { name: 'package-name', tools: { toolA, toolB } }; +export default block; +``` + +**index.ts (root):** +```typescript +export * from './src/index.js'; +export { default } from './src/index.js'; +``` + +Each tool gets its own entry in blocks.yml (same `path`) and in `tpmjs.tools` array. + +## Step 4: Validate + +```bash +cd packages/tools/official + +pnpm blocks run # Validate (schema → shape → domain) +pnpm blocks run --force # Force full validation (skip cache) +pnpm blocks run --json # JSON output for debugging +pnpm blocks run --all # Validate all tools +``` + +**Common errors:** +- `Tool "X" not found in exports` → Export name must match blocks.yml +- `Required file not found` → Check package root has all required files +- `invalid tpmjs field` → Category must be valid, tools array required + +## Step 5: Build and Publish + +```bash +pnpm --filter=@tpmjs/official- build +cd packages/tools/official/ && npm publish --access public +``` + +The tool syncs to tpmjs.com automatically via the changes feed (every 2 min) and keyword search (every 15 min). To trigger immediately: + +```bash +source apps/web/.env.local +curl -X POST https://tpmjs.com/api/sync/keyword \ + -H "Authorization: Bearer $CRON_SECRET" +``` + +## README Template + +Every tool needs a README: + +```markdown +# @tpmjs/official- + +Short description. + +## Installation + +npm install @tpmjs/official- + +## Usage + +\`\`\`typescript +import { myTool } from '@tpmjs/official-'; + +const result = await myTool.execute({ param1: 'example' }); +\`\`\` + +## Parameters + +| Name | Type | Required | Description | +|--------|--------|----------|--------------------| +| param1 | string | Yes | What param1 is for | + +## Output + +| Field | Type | Description | +|-------|--------|----------------------| +| data | string | The processed result | + +## License + +MIT +``` diff --git a/.claude/skills/tpmjs-tool-creator/references/domain.md b/.claude/skills/tpmjs-tool-creator/references/domain.md new file mode 100644 index 0000000..db62b0f --- /dev/null +++ b/.claude/skills/tpmjs-tool-creator/references/domain.md @@ -0,0 +1,58 @@ +# Domain Reference + +## Entities + +Reusable output types defined in blocks.yml. Reference these in your tool's output `type` field. + +| Entity | Fields | +|--------|--------| +| url | href, domain, protocol, path, query, fragment | +| webpage | url, title, html, text, metadata | +| text_content | raw, sentences, paragraphs, wordCount | +| claim | statement, confidence, needsCitation, category | +| timeline | events, dateRange, gaps, eventCount | +| evidence | source, type, strength, relevance | +| summary | text, keyPoints, length, compressionRatio | +| sentiment | score, label, confidence, aspects | +| entity | name, type, mentions, context | +| relationship | source, target, type, strength | +| pattern | name, frequency, examples, significance | +| anomaly | description, severity, context, recommendation | +| metric | name, value, unit, trend | +| comparison | items, criteria, rankings, analysis | +| recommendation | action, priority, rationale, impact | +| risk | description, likelihood, impact, mitigation | +| code_snippet | language, code, explanation, complexity | +| api_endpoint | method, path, parameters, response | +| data_schema | fields, types, constraints, relationships | +| workflow_step | action, input, output, conditions | + +## Quality Measures + +Reference these in your output's `measures` array. + +| Measure | Severity | What it checks | +|---------|----------|---------------| +| working_implementation | error | No TODOs, stubs, or placeholders. Returns actual computed values. | +| valid_output_structure | error | Returns object matching declared interface. All required fields present. Arrays never undefined. | +| proper_error_handling | error | Throws descriptive Error with context. Validates inputs. Catches external API errors. | +| ai_sdk_compliance | error | Uses `tool()` + `jsonSchema()` from 'ai'. Clear description. Every property has description. | +| npm_publishable | error | Valid package.json with tpmjs field. Named + default exports. Proper types. Semver version. | +| readme_documentation | error | README exists. Describes tool. Usage example. Documents inputs/outputs. | +| deterministic_output | warning | Same input produces same output (where applicable). | +| minimal_dependencies | warning | Uses stable, well-maintained packages. Avoids unnecessary deps. | + +## Domain Rules + +Common domain rule categories for the `domain_rules` field in blocks.yml: + +- **Core implementation**: working code, proper types, error handling +- **Web & fetch**: URL validation, content extraction, timeout handling +- **Document generation**: format compliance, template rendering +- **Data transformation**: schema validation, type coercion, encoding +- **Engineering/code analysis**: AST parsing, complexity metrics +- **Security & compliance**: input sanitization, safe execution +- **Statistical rigor**: numerical accuracy, proper rounding +- **Workflow/recipe**: step sequencing, state management + +Define custom rules specific to your tool's requirements. Each rule needs an `id` and `description`. diff --git a/.gitignore b/.gitignore index c32553e..2411202 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,18 @@ secrets.json # ide .idea +.agent/ +.agents/ +.continue/ +.cursor/ +.windsurf/ + +# temporary analysis docs +COMPREHENSIVE_ANALYSIS.md +PLAN.md +REGISTRY_TOOLS_ANALYSIS.md +USER_ACCOUNT_ANALYSIS_*.md +*.skill # storybook storybook-static diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 4254dcb..10b857e 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -12,9 +12,7 @@ const nextConfig: NextConfig = { '@tpmjs/registry-execute', ], reactStrictMode: true, - serverExternalPackages: [ - '@tpmjs/package-executor', - ], + serverExternalPackages: ['@tpmjs/package-executor'], async redirects() { return [ { diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx b/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx index 558482d..1805d24 100644 --- a/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx +++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx @@ -1,19 +1,15 @@ 'use client'; import { Badge } from '@tpmjs/ui/Badge/Badge'; -import { Button } from '@tpmjs/ui/Button/Button'; -import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock'; import { Icon } from '@tpmjs/ui/Icon/Icon'; import Link from 'next/link'; -import { useState } from 'react'; import { AppHeader } from '~/components/AppHeader'; -import { ForkButton } from '~/components/ForkButton'; +import { InstallationSection } from '~/components/collections/InstallationSection'; import { ForkedFromBadge } from '~/components/ForkedFromBadge'; import { LikeButton } from '~/components/LikeButton'; import { ScenariosSection } from '~/components/ScenariosSection'; import { ShareButton } from '~/components/ShareButton'; import { SkillsSection } from '~/components/skills/SkillsSection'; -import { useSession } from '~/lib/auth-client'; export interface CollectionTool { id: string; @@ -32,6 +28,31 @@ export interface CollectionTool { }; } +/** + * Locked state for private collections viewed by non-owners + * Shows minimal information: just name and "Private" badge + */ +export function PrivateCollectionLocked({ name }: { name: string }) { + return ( +
+ + +
+
+
+ +
+
+

{name}

+ Private +
+

This collection is private.

+
+
+
+ ); +} + export interface PublicCollection { id: string; slug: string; // Already coerced to empty string if null in server component @@ -59,193 +80,12 @@ export interface PublicCollection { } | null; } -function McpUrlSection({ - username, - slug, - isOwner, -}: { - username: string; - slug: string; - isOwner: boolean; -}) { - const [copiedUrl, setCopiedUrl] = useState<'http' | 'sse' | null>(null); - const [showConfig, setShowConfig] = useState(false); - const [showApiExample, setShowApiExample] = useState(false); - - const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com'; - const httpUrl = `${baseUrl}/api/mcp/${username}/${slug}/http`; - const sseUrl = `${baseUrl}/api/mcp/${username}/${slug}/sse`; - - const copyToClipboard = async (url: string, type: 'http' | 'sse') => { - await navigator.clipboard.writeText(url); - setCopiedUrl(type); - setTimeout(() => setCopiedUrl(null), 2000); - }; - - const configSnippet = `{ - "mcpServers": { - "tpmjs-${slug}": { - "command": "npx", - "args": [ - "mcp-remote", - "${httpUrl}" - ] - } - } -}`; - - const apiExampleSnippet = `// Call a tool with your own credentials -const response = await fetch("${httpUrl}", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer YOUR_TPMJS_API_KEY" - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "tools/call", - params: { - name: "tool-name", - arguments: { /* tool args */ }, - env: { - // Your env vars for the tools - "API_KEY": "your-key-here" - } - }, - id: 1 - }) -});`; - - return ( -
-
-
- -
-

MCP Server URLs

-
- -
- {/* HTTP Transport */} -
-
- - HTTP Transport - - (recommended) -
-
-
- {httpUrl} -
- -
-
- - {/* SSE Transport */} -
-
- - SSE Transport - - (streaming) -
-
-
- {sseUrl} -
- -
-
-
- - {/* Note for non-owners */} - {!isOwner && ( -
-

- - You'll need to provide your own API keys for any tools that require them. Pass - credentials via the{' '} - env parameter in your - API calls. -

-
- )} - - {/* Config snippet toggle */} -
- - - {showConfig && ( -
- -
- )} - - {!isOwner && ( - <> - - - {showApiExample && ( -
- -
- )} - - )} -
- -

- Use these URLs with{' '} - - Claude Desktop, Cursor, or any MCP client - -

-
- ); -} - interface CollectionDetailClientProps { collection: PublicCollection; username: string; } export function CollectionDetailClient({ collection, username }: CollectionDetailClientProps) { - const { data: session } = useSession(); - - // Check if current user is the owner - const isOwner = session?.user?.id && collection.createdBy?.id === session.user.id; - // Generate tweet text const tweetText = collection.description ? `${collection.name} - ${collection.description.slice(0, 100)}${collection.description.length > 100 ? '...' : ''}` @@ -289,7 +129,6 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai entityId={collection.id} initialCount={collection.likeCount} /> - @@ -311,8 +150,19 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai )} - {/* MCP Server URLs - Available to everyone (non-owners must provide their own credentials) */} - + {/* Installation Section */} + {/* Tools */} {collection.tools.length > 0 ? ( diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/mcp/route.ts b/apps/web/src/app/(profile)/[username]/collections/[slug]/mcp/route.ts new file mode 100644 index 0000000..ac577a4 --- /dev/null +++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/mcp/route.ts @@ -0,0 +1,359 @@ +import { prisma } from '@tpmjs/db'; +import { type NextRequest, NextResponse } from 'next/server'; + +import { API_KEY_SCOPES } from '~/lib/api-keys'; +import { authenticateRequest, getClientMetadata, hasScope } from '~/lib/api-keys/middleware'; +import { + checkApiKeyRateLimit, + createRateLimitResponse, + getRateLimitHeaders, +} from '~/lib/api-keys/rate-limit'; +import { trackUsage } from '~/lib/api-keys/usage'; +import { handleInitialize, handleToolsCall, handleToolsList } from '~/lib/mcp/handlers'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 60; + +const DB_TIMEOUT_MS = 10000; // 10 second timeout for database queries + +interface RouteContext { + params: Promise<{ username: string; slug: string }>; +} + +interface JsonRpcRequest { + jsonrpc: string; + method: string; + params?: unknown; + id?: string | number; +} + +interface JsonRpcResponse { + jsonrpc: '2.0'; + id: string | number | null; + result?: unknown; + error?: { code: number; message: string }; +} + +/** + * Wrap a promise with a timeout + */ +function withTimeout(promise: Promise, ms: number, errorMessage: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(errorMessage)), ms)), + ]); +} + +/** + * Find a user by username (strips @ prefix if present) + */ +async function getUserByUsername(username: string) { + // Strip @ prefix if present (from pretty URLs like /@username) + const cleanUsername = username.startsWith('@') ? username.slice(1) : username; + + return withTimeout( + prisma.user.findUnique({ + where: { username: cleanUsername }, + select: { id: true, username: true }, + }), + DB_TIMEOUT_MS, + `Database query timed out after ${DB_TIMEOUT_MS}ms` + ); +} + +/** + * Find a collection by user ID and slug + */ +async function getCollectionByUserIdAndSlug(userId: string, slug: string) { + return withTimeout( + prisma.collection.findFirst({ + where: { + userId, + slug, + }, + select: { id: true, name: true, description: true, userId: true, isPublic: true }, + }), + DB_TIMEOUT_MS, + `Database query timed out after ${DB_TIMEOUT_MS}ms` + ); +} + +/** + * Process a JSON-RPC request and return the response + */ +async function processJsonRpcRequest( + collectionId: string, + collectionName: string, + body: JsonRpcRequest, + isOwner: boolean +): Promise { + const requestId = body.id ?? null; + + switch (body.method) { + case 'initialize': + return handleInitialize(collectionName, requestId); + + case 'tools/list': + return await handleToolsList(collectionId, requestId); + + case 'tools/call': { + const params = body.params as { + name: string; + arguments?: Record; + env?: Record; + }; + + // For non-owners, use caller-provided env vars (or empty if not provided) + // For owners, callerEnvVars is undefined so handleToolsCall uses stored env vars + const callerEnvVars = isOwner ? undefined : params.env || {}; + + return await handleToolsCall(collectionId, params, requestId, callerEnvVars); + } + + case 'notifications/initialized': + case 'ping': + return { jsonrpc: '2.0', id: requestId, result: {} }; + + default: + return { + jsonrpc: '2.0', + id: requestId, + error: { code: -32601, message: `Method not found: ${body.method}` }, + }; + } +} + +/** + * POST /@username/collections/[slug]/mcp + * MCP JSON-RPC endpoint (HTTP transport only) + * + * Authentication: + * - Public collections: No auth required + * - Private collections: Requires Authorization: Bearer header with valid API key + */ +export async function POST(request: NextRequest, context: RouteContext): Promise { + const startTime = Date.now(); + let authResult: Awaited> | null = null; + + try { + const { username, slug } = await context.params; + + // First, find the user by username + const user = await getUserByUsername(username); + + if (!user) { + return NextResponse.json( + { + jsonrpc: '2.0', + error: { + code: -32001, + message: `User '${username}' not found. Check the username in your MCP endpoint URL.`, + }, + id: null, + }, + { status: 404 } + ); + } + + // Then find the collection by user ID and slug + const collection = await getCollectionByUserIdAndSlug(user.id, slug); + + if (!collection) { + return NextResponse.json( + { + jsonrpc: '2.0', + error: { + code: -32001, + message: `Collection '${slug}' not found for user '${user.username}'.`, + }, + id: null, + }, + { status: 404 } + ); + } + + // Authenticate the request + authResult = await authenticateRequest(); + + // Determine if the authenticated user is the owner + const isOwner = authResult.authenticated && authResult.userId === collection.userId; + + // Authorization check: + // - Owners can always access their own collections (public or private) + // - Non-owners can access PUBLIC collections without auth + // - Private collections require auth as the owner + if (!isOwner && !collection.isPublic) { + // Private collection, not the owner - require authentication + if (!authResult.authenticated) { + return NextResponse.json( + { + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Authentication required. Add header: Authorization: Bearer YOUR_API_KEY', + }, + id: null, + }, + { status: 401 } + ); + } + // Authenticated but not the owner of a private collection - don't reveal existence + return NextResponse.json( + { jsonrpc: '2.0', error: { code: -32001, message: 'Collection not found' }, id: null }, + { status: 404 } + ); + } + + // Check scope if authenticated + if (authResult.authenticated && !hasScope(authResult, API_KEY_SCOPES.MCP_EXECUTE)) { + return NextResponse.json( + { + jsonrpc: '2.0', + error: { code: -32000, message: 'Missing required scope: mcp:execute' }, + id: null, + }, + { status: 403 } + ); + } + + // Rate limit if authenticated via API key + if (authResult.authenticated && authResult.apiKeyId) { + const rateLimitResult = await checkApiKeyRateLimit( + authResult.apiKeyId, + authResult.tier || 'FREE' + ); + + if (!rateLimitResult.allowed) { + return createRateLimitResponse(rateLimitResult); + } + } + + // Parse JSON-RPC request body + let body: JsonRpcRequest; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null }, + { status: 400 } + ); + } + + // Process the request + const response = await processJsonRpcRequest(collection.id, collection.name, body, isOwner); + const jsonResponse = NextResponse.json(response); + + // Track usage for authenticated requests + if (authResult.authenticated && authResult.userId) { + const clientMeta = await getClientMetadata(); + trackUsage({ + apiKeyId: authResult.apiKeyId, + userId: authResult.userId, + endpoint: `/@${user.username}/collections/${slug}/mcp`, + method: 'POST', + statusCode: jsonResponse.status, + latencyMs: Date.now() - startTime, + resourceType: 'mcp', + resourceId: collection.id, + userAgent: clientMeta.userAgent, + ipAddress: clientMeta.ipAddress, + }); + } + + // Add rate limit headers for authenticated requests + if (authResult.authenticated && authResult.apiKeyId) { + const rateLimitResult = await checkApiKeyRateLimit( + authResult.apiKeyId, + authResult.tier || 'FREE' + ); + const headers = getRateLimitHeaders(rateLimitResult); + for (const [key, value] of Object.entries(headers)) { + jsonResponse.headers.set(key, value); + } + } + + return jsonResponse; + } catch (error) { + console.error('[MCP POST] Error:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + + // Track error for authenticated requests + if (authResult?.authenticated && authResult.userId) { + const { username, slug } = await context.params; + const clientMeta = await getClientMetadata(); + trackUsage({ + apiKeyId: authResult.apiKeyId, + userId: authResult.userId, + endpoint: `/@${username}/collections/${slug}/mcp`, + method: 'POST', + statusCode: 500, + latencyMs: Date.now() - startTime, + resourceType: 'mcp', + errorCode: 'INTERNAL_ERROR', + errorMessage: message, + userAgent: clientMeta.userAgent, + ipAddress: clientMeta.ipAddress, + }); + } + + return NextResponse.json( + { jsonrpc: '2.0', error: { code: -32603, message }, id: null }, + { status: 500 } + ); + } +} + +/** + * GET /@username/collections/[slug]/mcp + * Returns server info for the MCP endpoint + */ +export async function GET(_request: NextRequest, context: RouteContext): Promise { + try { + const { username, slug } = await context.params; + + // First, find the user by username + const user = await getUserByUsername(username); + + if (!user) { + return NextResponse.json( + { error: `User '${username}' not found. Check the username in your MCP endpoint URL.` }, + { status: 404 } + ); + } + + // Then find the collection + const collection = await getCollectionByUserIdAndSlug(user.id, slug); + + if (!collection) { + return NextResponse.json( + { error: `Collection '${slug}' not found for user '${user.username}'.` }, + { status: 404 } + ); + } + + // For GET requests, check if user can access this collection: + // - Public collections are accessible to anyone + // - Private collections are only accessible to the owner (when authenticated) + if (!collection.isPublic) { + const authResult = await authenticateRequest(); + if (!authResult.authenticated || authResult.userId !== collection.userId) { + // Don't reveal existence of private collections + return NextResponse.json({ error: 'Collection not found' }, { status: 404 }); + } + } + + // Return server info + return NextResponse.json({ + name: `TPMJS: ${collection.name}`, + description: collection.description, + protocol: 'mcp', + transport: 'http', + endpoint: `/@${user.username}/collections/${slug}/mcp`, + }); + } catch (error) { + console.error('[MCP GET] Error:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/page.tsx b/apps/web/src/app/(profile)/[username]/collections/[slug]/page.tsx index 1b5e97f..4e01bd9 100644 --- a/apps/web/src/app/(profile)/[username]/collections/[slug]/page.tsx +++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/page.tsx @@ -1,7 +1,11 @@ import { prisma } from '@tpmjs/db'; import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { CollectionDetailClient, type PublicCollection } from './CollectionDetailClient'; +import { + CollectionDetailClient, + PrivateCollectionLocked, + type PublicCollection, +} from './CollectionDetailClient'; export const dynamic = 'force-dynamic'; @@ -9,18 +13,25 @@ interface CollectionPageProps { params: Promise<{ username: string; slug: string }>; } +interface CollectionResult { + collection: PublicCollection | null; + isPrivate: boolean; + privateName?: string; +} + /** * Fetch collection data from database + * Returns both public collections fully, and private collections with minimal info (locked state) */ -async function getCollection(username: string, slug: string): Promise { +async function getCollection(username: string, slug: string): Promise { // Remove @ prefix if present const cleanUsername = username.startsWith('@') ? username.slice(1) : username; + // First, check if the collection exists at all (public or private) const collection = await prisma.collection.findFirst({ where: { slug, user: { username: cleanUsername }, - isPublic: true, }, include: { user: { @@ -57,51 +68,64 @@ async function getCollection(username: string, slug: string): Promise ({ - id: ct.id, - toolId: ct.toolId, - position: ct.position, - note: ct.note, - tool: { - id: ct.tool.id, - name: ct.tool.name, - description: ct.tool.description, - likeCount: ct.tool.likeCount, - package: { - npmPackageName: ct.tool.package.npmPackageName, - category: ct.tool.package.category, - }, + collection: { + id: collection.id, + slug: collection.slug || '', + name: collection.name, + description: collection.description, + likeCount: collection.likeCount, + toolCount: collection.tools.length, + forkCount: collection.forkCount, + createdAt: collection.createdAt.toISOString(), + createdBy: { + id: collection.user.id, + username: collection.user.username || '', + name: collection.user.name || '', + image: collection.user.image, }, - })), - forkedFromId: collection.forkedFromId, - forkedFrom: collection.forkedFrom - ? { - id: collection.forkedFrom.id, - name: collection.forkedFrom.name, - slug: collection.forkedFrom.slug || '', - user: { - username: collection.forkedFrom.user.username || '', + tools: collection.tools.map((ct) => ({ + id: ct.id, + toolId: ct.toolId, + position: ct.position, + note: ct.note, + tool: { + id: ct.tool.id, + name: ct.tool.name, + description: ct.tool.description, + likeCount: ct.tool.likeCount, + package: { + npmPackageName: ct.tool.package.npmPackageName, + category: ct.tool.package.category, }, - } - : null, + }, + })), + forkedFromId: collection.forkedFromId, + forkedFrom: collection.forkedFrom + ? { + id: collection.forkedFrom.id, + name: collection.forkedFrom.name, + slug: collection.forkedFrom.slug || '', + user: { + username: collection.forkedFrom.user.username || '', + }, + } + : null, + }, + isPrivate: false, }; } @@ -111,15 +135,25 @@ async function getCollection(username: string, slug: string): Promise { const { username, slug } = await params; const cleanUsername = username.startsWith('@') ? username.slice(1) : username; - const collection = await getCollection(username, slug); + const result = await getCollection(username, slug); - if (!collection) { + // Private collection - minimal metadata + if (result.isPrivate) { + return { + title: `${result.privateName} (Private) | TPMJS`, + description: 'This collection is private.', + robots: { index: false, follow: false }, + }; + } + + if (!result.collection) { return { title: 'Collection Not Found | TPMJS', description: 'The requested collection could not be found.', }; } + const collection = result.collection; const title = `${collection.name} | TPMJS`; const description = collection.description || @@ -174,11 +208,17 @@ export async function generateMetadata({ params }: CollectionPageProps): Promise export default async function CollectionDetailPage({ params }: CollectionPageProps) { const { username, slug } = await params; const cleanUsername = username.startsWith('@') ? username.slice(1) : username; - const collection = await getCollection(username, slug); + const result = await getCollection(username, slug); - if (!collection) { + // Private collection - show locked state + if (result.isPrivate && result.privateName) { + return ; + } + + // Collection not found + if (!result.collection) { notFound(); } - return ; + return ; } diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/QuestionsListClient.tsx b/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/QuestionsListClient.tsx index 8ebc337..d84dac2 100644 --- a/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/QuestionsListClient.tsx +++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/QuestionsListClient.tsx @@ -131,7 +131,11 @@ export function QuestionsListClient({ const clearSkillFilter = () => { setSkillFilter(undefined); - window.history.replaceState(null, '', `/${collection.username}/collections/${collection.slug}/skills/questions`); + window.history.replaceState( + null, + '', + `/${collection.username}/collections/${collection.slug}/skills/questions` + ); }; const basePath = `/${collection.username}/collections/${collection.slug}`; @@ -224,7 +228,7 @@ export function QuestionsListClient({ description={ skillFilter ? `No questions found for skill "${skillFilter}"` - : 'Be the first to ask a question about this collection\'s tools.' + : "Be the first to ask a question about this collection's tools." } size="md" /> @@ -243,11 +247,7 @@ export function QuestionsListClient({ {!loading && !error && questions.length > 0 && (
{questions.map((q) => ( - + - -
- - - {/* SSE Transport */} -
-
- - SSE Transport - - (streaming) -
-
-
- {sseUrl} -
- -
-
- - - {/* Config snippet toggle */} -
- - - {showConfig && ( -
-
-              {configSnippet}
-            
- -
- )} -
- -

- Use these URLs with{' '} - - Claude Desktop, Cursor, or any MCP client - -

- - ); -} - -export default function PublicCollectionDetailPage(): React.ReactElement { - const params = useParams(); - const router = useRouter(); - const collectionId = params.id as string; - - const [collection, setCollection] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchCollection = useCallback(async () => { - try { - const response = await fetch(`/api/public/collections/${collectionId}`); - const data = await response.json(); - - if (data.success) { - // Redirect to pretty URL if username and slug are available - if (data.data.createdBy?.username && data.data.slug) { - router.replace(`/${data.data.createdBy.username}/collections/${data.data.slug}`); - return; - } - setCollection(data.data); - } else { - if (data.error?.code === 'NOT_FOUND' || data.error?.code === 'FORBIDDEN') { - setError('This collection is not available or is private'); - } else { - setError(data.error?.message || 'Failed to fetch collection'); - } - } - } catch (err) { - console.error('Failed to fetch collection:', err); - setError('Failed to fetch collection'); - } finally { - setIsLoading(false); - } - }, [collectionId, router]); - - useEffect(() => { - fetchCollection(); - }, [fetchCollection]); - - if (isLoading) { - return ( -
- -
-
-
-
-
-
- {[1, 2, 3].map((i) => ( -
- ))} -
-
-
-
- ); + // If collection doesn't exist, return 404 + if (!collection) { + notFound(); } - if (error || !collection) { - return ( -
- -
-
- -

- {error || 'Collection not found'} -

-

- This collection may be private or no longer available. -

- - - -
-
-
- ); + // If collection is private, return 404 (don't reveal existence) + if (!collection.isPublic) { + notFound(); } - return ( -
- + // If user has no username or collection has no slug, can't redirect to pretty URL + if (!collection.user.username || !collection.slug) { + notFound(); + } -
- {/* Back link */} - - - Back to Collections - - - {/* Header */} -
-
-

{collection.name}

- {collection.description && ( -

{collection.description}

- )} -
- -
- - {/* Meta info */} -
-
- {collection.createdBy.image ? ( - {collection.createdBy.name} - ) : ( -
- -
- )} - Created by {collection.createdBy.name} -
- - - {collection.toolCount} tool{collection.toolCount !== 1 ? 's' : ''} - -
- - {/* MCP URLs */} - {collection.createdBy?.username && collection.slug && ( - - )} - - {/* Tools */} -
-

Tools in this Collection

- - {collection.tools.length === 0 ? ( -
- -

No tools in this collection yet

-
- ) : ( -
- {collection.tools.map((ct) => ( -
-
-
- - {ct.tool.name} - - - from {ct.tool.package.npmPackageName} - -
- -
-

- {ct.tool.description} -

- - {ct.tool.package.category} - - {ct.note && ( -

Note: {ct.note}

- )} -
- ))} -
- )} -
-
-
- ); + // 301 permanent redirect to the canonical URL + redirect(`/@${collection.user.username}/collections/${collection.slug}`); } diff --git a/apps/web/src/app/collections/page.tsx b/apps/web/src/app/collections/page.tsx index 98cd3d1..cd26b00 100644 --- a/apps/web/src/app/collections/page.tsx +++ b/apps/web/src/app/collections/page.tsx @@ -1,294 +1,10 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { Badge } from '@tpmjs/ui/Badge/Badge'; -import { EmptyState } from '@tpmjs/ui/EmptyState/EmptyState'; -import { ErrorState } from '@tpmjs/ui/ErrorState/ErrorState'; -import { Icon } from '@tpmjs/ui/Icon/Icon'; -import { Input } from '@tpmjs/ui/Input/Input'; -import { LoadingState } from '@tpmjs/ui/LoadingState/LoadingState'; -import { PageHeader } from '@tpmjs/ui/PageHeader/PageHeader'; -import { Select } from '@tpmjs/ui/Select/Select'; -import Link from 'next/link'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { TableVirtuoso } from 'react-virtuoso'; -import { AppHeader } from '~/components/AppHeader'; -import { CopyDropdown, getCollectionCopyOptions } from '~/components/CopyDropdown'; -import { LikeButton } from '~/components/LikeButton'; - -interface PublicCollection { - id: string; - slug: string; - name: string; - description: string | null; - likeCount: number; - toolCount: number; - createdAt: string; - createdBy: { - id: string; - name: string; - image: string | null; - username: string | null; - }; -} - -type SortOption = 'likes' | 'recent' | 'tools'; - -function sortCollections(collections: PublicCollection[], sortBy: SortOption): PublicCollection[] { - return [...collections].sort((a, b) => { - switch (sortBy) { - case 'likes': - return b.likeCount - a.likeCount; - case 'recent': - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - case 'tools': - return b.toolCount - a.toolCount; - default: - return 0; - } - }); -} - -function truncateText(text: string, maxLength: number): string { - if (text.length <= maxLength) return text; - return `${text.slice(0, maxLength).trim()}...`; -} - -export default function PublicCollectionsPage(): React.ReactElement { - const [collections, setCollections] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [search, setSearch] = useState(''); - const [sort, setSort] = useState('likes'); - const loadingMore = useRef(false); - - const fetchCollections = useCallback( - async (offset: number, resetList = false) => { - try { - if (loadingMore.current && !resetList) return; - loadingMore.current = true; - - const params = new URLSearchParams({ - limit: '100', - offset: String(offset), - sort, - }); - - const response = await fetch(`/api/public/collections?${params}`); - const data = await response.json(); - - if (data.success) { - if (resetList || offset === 0) { - setCollections(data.data); - } else { - setCollections((prev) => [...prev, ...data.data]); - } - setHasMore(data.pagination.hasMore); - } else { - setError(data.error?.message || 'Failed to fetch collections'); - } - } catch (err) { - console.error('Failed to fetch collections:', err); - setError('Failed to fetch collections'); - } finally { - setIsLoading(false); - loadingMore.current = false; - } - }, - [sort] - ); - - useEffect(() => { - setIsLoading(true); - fetchCollections(0, true); - }, [fetchCollections]); - - const loadMore = useCallback(() => { - if (!hasMore || loadingMore.current) return; - fetchCollections(collections.length); - }, [hasMore, collections.length, fetchCollections]); - - // Filter and sort collections - const filteredCollections = useMemo(() => { - let result = collections; - - if (search) { - const query = search.toLowerCase(); - result = result.filter( - (c) => c.name.toLowerCase().includes(query) || c.description?.toLowerCase().includes(query) - ); - } - - return sortCollections(result, sort); - }, [collections, search, sort]); - - const TableHeader = useCallback( - () => ( - - Name - Description - Tools - Likes - Creator - Copy - - ), - [] - ); - - const TableRow = useCallback((_index: number, collection: PublicCollection) => { - return ( - <> - - - {collection.name} - - - - {collection.description ? truncateText(collection.description, 60) : '—'} - - - - {collection.toolCount} - - - - - - -
- {collection.createdBy.image ? ( - {collection.createdBy.name} - ) : ( -
- -
- )} - - {collection.createdBy.name} - -
- - - {collection.createdBy.username && ( - - )} - - - ); - }, []); - - return ( -
- - -
- - - {/* Filters */} -
-
- setSearch(e.target.value)} - placeholder="Search collections..." - /> -
- -
- Sort: -