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
This commit is contained in:
parent
f3a46045ba
commit
a52d32c367
40 changed files with 3310 additions and 1694 deletions
290
.claude/skills/tpmjs-tool-creator/SKILL.md
Normal file
290
.claude/skills/tpmjs-tool-creator/SKILL.md
Normal file
|
|
@ -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 <tool-name>`
|
||||||
|
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/<tool-name>/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
<tool-name>/
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── tsup.config.ts
|
||||||
|
├── README.md
|
||||||
|
└── src/
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**package.json:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@tpmjs/official-<tool-name>",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Short description",
|
||||||
|
"type": "module",
|
||||||
|
"keywords": ["tpmjs", "<category>", "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/<tool-name>"
|
||||||
|
},
|
||||||
|
"homepage": "https://tpmjs.com",
|
||||||
|
"license": "MIT",
|
||||||
|
"tpmjs": {
|
||||||
|
"category": "<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<MyToolInput>({
|
||||||
|
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<MyToolResult> => {
|
||||||
|
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 <tool-name> # Validate (schema → shape → domain)
|
||||||
|
pnpm blocks run <tool-name> --force # Force full validation (skip cache)
|
||||||
|
pnpm blocks run <tool-name> --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-<tool-name> build
|
||||||
|
cd packages/tools/official/<tool-name> && 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-<tool-name>
|
||||||
|
|
||||||
|
Short description.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
npm install @tpmjs/official-<tool-name>
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
import { myTool } from '@tpmjs/official-<tool-name>';
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
58
.claude/skills/tpmjs-tool-creator/references/domain.md
Normal file
58
.claude/skills/tpmjs-tool-creator/references/domain.md
Normal file
|
|
@ -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`.
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -63,6 +63,18 @@ secrets.json
|
||||||
|
|
||||||
# ide
|
# ide
|
||||||
.idea
|
.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
|
||||||
storybook-static
|
storybook-static
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,7 @@ const nextConfig: NextConfig = {
|
||||||
'@tpmjs/registry-execute',
|
'@tpmjs/registry-execute',
|
||||||
],
|
],
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
serverExternalPackages: [
|
serverExternalPackages: ['@tpmjs/package-executor'],
|
||||||
'@tpmjs/package-executor',
|
|
||||||
],
|
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,15 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
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 { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
|
||||||
import { AppHeader } from '~/components/AppHeader';
|
import { AppHeader } from '~/components/AppHeader';
|
||||||
import { ForkButton } from '~/components/ForkButton';
|
import { InstallationSection } from '~/components/collections/InstallationSection';
|
||||||
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
|
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
|
||||||
import { LikeButton } from '~/components/LikeButton';
|
import { LikeButton } from '~/components/LikeButton';
|
||||||
import { ScenariosSection } from '~/components/ScenariosSection';
|
import { ScenariosSection } from '~/components/ScenariosSection';
|
||||||
import { ShareButton } from '~/components/ShareButton';
|
import { ShareButton } from '~/components/ShareButton';
|
||||||
import { SkillsSection } from '~/components/skills/SkillsSection';
|
import { SkillsSection } from '~/components/skills/SkillsSection';
|
||||||
import { useSession } from '~/lib/auth-client';
|
|
||||||
|
|
||||||
export interface CollectionTool {
|
export interface CollectionTool {
|
||||||
id: string;
|
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 (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<main className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-foreground-tertiary/10 flex items-center justify-center mb-6">
|
||||||
|
<Icon icon="key" className="w-8 h-8 text-foreground-tertiary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">{name}</h1>
|
||||||
|
<Badge variant="secondary">Private</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-foreground-secondary">This collection is private.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface PublicCollection {
|
export interface PublicCollection {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string; // Already coerced to empty string if null in server component
|
slug: string; // Already coerced to empty string if null in server component
|
||||||
|
|
@ -59,193 +80,12 @@ export interface PublicCollection {
|
||||||
} | null;
|
} | 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 (
|
|
||||||
<section className="p-4 bg-gradient-to-br from-primary/5 via-transparent to-primary/5 border border-primary/20 rounded-xl">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<div className="p-1.5 bg-primary/10 rounded-lg">
|
|
||||||
<Icon icon="link" className="w-4 h-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h3 className="font-semibold text-foreground">MCP Server URLs</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* HTTP Transport */}
|
|
||||||
<div className="group">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
|
|
||||||
HTTP Transport
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-foreground-tertiary">(recommended)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
|
|
||||||
{httpUrl}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => copyToClipboard(httpUrl, 'http')}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
<Icon icon={copiedUrl === 'http' ? 'check' : 'copy'} className="w-4 h-4 mr-1" />
|
|
||||||
{copiedUrl === 'http' ? 'Copied!' : 'Copy'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SSE Transport */}
|
|
||||||
<div className="group">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
|
|
||||||
SSE Transport
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-foreground-tertiary">(streaming)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
|
|
||||||
{sseUrl}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => copyToClipboard(sseUrl, 'sse')}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
<Icon icon={copiedUrl === 'sse' ? 'check' : 'copy'} className="w-4 h-4 mr-1" />
|
|
||||||
{copiedUrl === 'sse' ? 'Copied!' : 'Copy'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Note for non-owners */}
|
|
||||||
{!isOwner && (
|
|
||||||
<div className="mt-4 p-3 bg-warning/10 border border-warning/20 rounded-lg">
|
|
||||||
<p className="text-sm text-warning-foreground">
|
|
||||||
<Icon icon="info" className="w-4 h-4 inline mr-1" />
|
|
||||||
You'll need to provide your own API keys for any tools that require them. Pass
|
|
||||||
credentials via the{' '}
|
|
||||||
<code className="font-mono text-xs bg-surface px-1 rounded">env</code> parameter in your
|
|
||||||
API calls.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Config snippet toggle */}
|
|
||||||
<div className="mt-4 pt-4 border-t border-border/50 space-y-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowConfig(!showConfig)}
|
|
||||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
|
||||||
>
|
|
||||||
<Icon icon={showConfig ? 'chevronDown' : 'chevronRight'} className="w-4 h-4" />
|
|
||||||
<span>Show Claude Desktop config</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showConfig && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<CodeBlock language="json" code={configSnippet} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isOwner && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowApiExample(!showApiExample)}
|
|
||||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
|
||||||
>
|
|
||||||
<Icon icon={showApiExample ? 'chevronDown' : 'chevronRight'} className="w-4 h-4" />
|
|
||||||
<span>Show API usage example</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showApiExample && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<CodeBlock language="typescript" code={apiExampleSnippet} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-3 text-xs text-foreground-tertiary">
|
|
||||||
Use these URLs with{' '}
|
|
||||||
<Link href="/docs/sharing" className="text-primary hover:underline">
|
|
||||||
Claude Desktop, Cursor, or any MCP client
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CollectionDetailClientProps {
|
interface CollectionDetailClientProps {
|
||||||
collection: PublicCollection;
|
collection: PublicCollection;
|
||||||
username: string;
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CollectionDetailClient({ collection, username }: CollectionDetailClientProps) {
|
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
|
// Generate tweet text
|
||||||
const tweetText = collection.description
|
const tweetText = collection.description
|
||||||
? `${collection.name} - ${collection.description.slice(0, 100)}${collection.description.length > 100 ? '...' : ''}`
|
? `${collection.name} - ${collection.description.slice(0, 100)}${collection.description.length > 100 ? '...' : ''}`
|
||||||
|
|
@ -289,7 +129,6 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai
|
||||||
entityId={collection.id}
|
entityId={collection.id}
|
||||||
initialCount={collection.likeCount}
|
initialCount={collection.likeCount}
|
||||||
/>
|
/>
|
||||||
<ForkButton type="collection" sourceId={collection.id} sourceName={collection.name} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -311,8 +150,19 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MCP Server URLs - Available to everyone (non-owners must provide their own credentials) */}
|
{/* Installation Section */}
|
||||||
<McpUrlSection username={username} slug={collection.slug} isOwner={!!isOwner} />
|
<InstallationSection
|
||||||
|
collection={{
|
||||||
|
id: collection.id,
|
||||||
|
slug: collection.slug,
|
||||||
|
name: collection.name,
|
||||||
|
toolCount: collection.toolCount,
|
||||||
|
envVars: null, // Public collections don't expose env vars
|
||||||
|
}}
|
||||||
|
username={username}
|
||||||
|
isPrivate={false}
|
||||||
|
showForkButton={true}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Tools */}
|
{/* Tools */}
|
||||||
{collection.tools.length > 0 ? (
|
{collection.tools.length > 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -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<T>(promise: Promise<T>, ms: number, errorMessage: string): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T>((_, 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<JsonRpcResponse> {
|
||||||
|
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<string, unknown>;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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<Response> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let authResult: Awaited<ReturnType<typeof authenticateRequest>> | 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<Response> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { prisma } from '@tpmjs/db';
|
import { prisma } from '@tpmjs/db';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { CollectionDetailClient, type PublicCollection } from './CollectionDetailClient';
|
import {
|
||||||
|
CollectionDetailClient,
|
||||||
|
PrivateCollectionLocked,
|
||||||
|
type PublicCollection,
|
||||||
|
} from './CollectionDetailClient';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -9,18 +13,25 @@ interface CollectionPageProps {
|
||||||
params: Promise<{ username: string; slug: string }>;
|
params: Promise<{ username: string; slug: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CollectionResult {
|
||||||
|
collection: PublicCollection | null;
|
||||||
|
isPrivate: boolean;
|
||||||
|
privateName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch collection data from database
|
* 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<PublicCollection | null> {
|
async function getCollection(username: string, slug: string): Promise<CollectionResult> {
|
||||||
// Remove @ prefix if present
|
// Remove @ prefix if present
|
||||||
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
|
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({
|
const collection = await prisma.collection.findFirst({
|
||||||
where: {
|
where: {
|
||||||
slug,
|
slug,
|
||||||
user: { username: cleanUsername },
|
user: { username: cleanUsername },
|
||||||
isPublic: true,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
|
|
@ -57,51 +68,64 @@ async function getCollection(username: string, slug: string): Promise<PublicColl
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!collection) {
|
if (!collection) {
|
||||||
return null;
|
return { collection: null, isPrivate: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If private, return minimal info for locked state
|
||||||
|
if (!collection.isPublic) {
|
||||||
|
return {
|
||||||
|
collection: null,
|
||||||
|
isPrivate: true,
|
||||||
|
privateName: collection.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public collection - return full data
|
||||||
return {
|
return {
|
||||||
id: collection.id,
|
collection: {
|
||||||
slug: collection.slug || '',
|
id: collection.id,
|
||||||
name: collection.name,
|
slug: collection.slug || '',
|
||||||
description: collection.description,
|
name: collection.name,
|
||||||
likeCount: collection.likeCount,
|
description: collection.description,
|
||||||
toolCount: collection.tools.length,
|
likeCount: collection.likeCount,
|
||||||
forkCount: collection.forkCount,
|
toolCount: collection.tools.length,
|
||||||
createdAt: collection.createdAt.toISOString(),
|
forkCount: collection.forkCount,
|
||||||
createdBy: {
|
createdAt: collection.createdAt.toISOString(),
|
||||||
id: collection.user.id,
|
createdBy: {
|
||||||
username: collection.user.username || '',
|
id: collection.user.id,
|
||||||
name: collection.user.name || '',
|
username: collection.user.username || '',
|
||||||
image: collection.user.image,
|
name: collection.user.name || '',
|
||||||
},
|
image: collection.user.image,
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})),
|
tools: collection.tools.map((ct) => ({
|
||||||
forkedFromId: collection.forkedFromId,
|
id: ct.id,
|
||||||
forkedFrom: collection.forkedFrom
|
toolId: ct.toolId,
|
||||||
? {
|
position: ct.position,
|
||||||
id: collection.forkedFrom.id,
|
note: ct.note,
|
||||||
name: collection.forkedFrom.name,
|
tool: {
|
||||||
slug: collection.forkedFrom.slug || '',
|
id: ct.tool.id,
|
||||||
user: {
|
name: ct.tool.name,
|
||||||
username: collection.forkedFrom.user.username || '',
|
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<PublicColl
|
||||||
export async function generateMetadata({ params }: CollectionPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: CollectionPageProps): Promise<Metadata> {
|
||||||
const { username, slug } = await params;
|
const { username, slug } = await params;
|
||||||
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
|
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 {
|
return {
|
||||||
title: 'Collection Not Found | TPMJS',
|
title: 'Collection Not Found | TPMJS',
|
||||||
description: 'The requested collection could not be found.',
|
description: 'The requested collection could not be found.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collection = result.collection;
|
||||||
const title = `${collection.name} | TPMJS`;
|
const title = `${collection.name} | TPMJS`;
|
||||||
const description =
|
const description =
|
||||||
collection.description ||
|
collection.description ||
|
||||||
|
|
@ -174,11 +208,17 @@ export async function generateMetadata({ params }: CollectionPageProps): Promise
|
||||||
export default async function CollectionDetailPage({ params }: CollectionPageProps) {
|
export default async function CollectionDetailPage({ params }: CollectionPageProps) {
|
||||||
const { username, slug } = await params;
|
const { username, slug } = await params;
|
||||||
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
|
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 <PrivateCollectionLocked name={result.privateName} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection not found
|
||||||
|
if (!result.collection) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CollectionDetailClient collection={collection} username={cleanUsername} />;
|
return <CollectionDetailClient collection={result.collection} username={cleanUsername} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,11 @@ export function QuestionsListClient({
|
||||||
|
|
||||||
const clearSkillFilter = () => {
|
const clearSkillFilter = () => {
|
||||||
setSkillFilter(undefined);
|
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}`;
|
const basePath = `/${collection.username}/collections/${collection.slug}`;
|
||||||
|
|
@ -224,7 +228,7 @@ export function QuestionsListClient({
|
||||||
description={
|
description={
|
||||||
skillFilter
|
skillFilter
|
||||||
? `No questions found for skill "${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"
|
size="md"
|
||||||
/>
|
/>
|
||||||
|
|
@ -243,11 +247,7 @@ export function QuestionsListClient({
|
||||||
{!loading && !error && questions.length > 0 && (
|
{!loading && !error && questions.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{questions.map((q) => (
|
{questions.map((q) => (
|
||||||
<Link
|
<Link key={q.id} href={`${basePath}/skills/questions/${q.id}`} className="block">
|
||||||
key={q.id}
|
|
||||||
href={`${basePath}/skills/questions/${q.id}`}
|
|
||||||
className="block"
|
|
||||||
>
|
|
||||||
<Card
|
<Card
|
||||||
variant="default"
|
variant="default"
|
||||||
className="hover:border-primary/30 hover:bg-muted/30 transition-all cursor-pointer"
|
className="hover:border-primary/30 hover:bg-muted/30 transition-all cursor-pointer"
|
||||||
|
|
@ -311,11 +311,7 @@ export function QuestionsListClient({
|
||||||
{/* Load More */}
|
{/* Load More */}
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className="text-center pt-4">
|
<div className="text-center pt-4">
|
||||||
<Button
|
<Button variant="secondary" onClick={handleLoadMore} disabled={loadingMore}>
|
||||||
variant="secondary"
|
|
||||||
onClick={handleLoadMore}
|
|
||||||
disabled={loadingMore}
|
|
||||||
>
|
|
||||||
{loadingMore ? (
|
{loadingMore ? (
|
||||||
<>
|
<>
|
||||||
<Icon icon="loader" className="w-4 h-4 mr-2 animate-spin" />
|
<Icon icon="loader" className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
|
|
||||||
|
|
@ -90,9 +90,10 @@ export function QuestionDetailClient({
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const basePath = `/${collection.username}/collections/${collection.slug}`;
|
const basePath = `/${collection.username}/collections/${collection.slug}`;
|
||||||
const questionUrl = typeof window !== 'undefined'
|
const questionUrl =
|
||||||
? window.location.href
|
typeof window !== 'undefined'
|
||||||
: `https://tpmjs.com${basePath}/skills/questions/${question.id}`;
|
? window.location.href
|
||||||
|
: `https://tpmjs.com${basePath}/skills/questions/${question.id}`;
|
||||||
|
|
||||||
const copyLink = async () => {
|
const copyLink = async () => {
|
||||||
await navigator.clipboard.writeText(questionUrl);
|
await navigator.clipboard.writeText(questionUrl);
|
||||||
|
|
@ -140,10 +141,7 @@ export function QuestionDetailClient({
|
||||||
<Icon icon="clock" className="w-4 h-4" />
|
<Icon icon="clock" className="w-4 h-4" />
|
||||||
{formatDate(question.createdAt)}
|
{formatDate(question.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge variant={question.confidence >= 0.7 ? 'success' : 'secondary'} size="md">
|
||||||
variant={question.confidence >= 0.7 ? 'success' : 'secondary'}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
{Math.round(question.confidence * 100)}% confidence
|
{Math.round(question.confidence * 100)}% confidence
|
||||||
</Badge>
|
</Badge>
|
||||||
{question.similarCount > 0 && (
|
{question.similarCount > 0 && (
|
||||||
|
|
@ -233,7 +231,9 @@ export function QuestionDetailClient({
|
||||||
className="block p-2 bg-muted/50 border border-border rounded hover:border-primary/30 transition-colors"
|
className="block p-2 bg-muted/50 border border-border rounded hover:border-primary/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium text-sm text-foreground">{sn.skill.name}</span>
|
<span className="font-medium text-sm text-foreground">
|
||||||
|
{sn.skill.name}
|
||||||
|
</span>
|
||||||
<Badge variant="outline" size="sm">
|
<Badge variant="outline" size="sm">
|
||||||
{sn.skill.questionCount} Q
|
{sn.skill.questionCount} Q
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -288,10 +288,7 @@ export function QuestionDetailClient({
|
||||||
>
|
>
|
||||||
<p className="text-sm text-foreground line-clamp-2">{sq.question}</p>
|
<p className="text-sm text-foreground line-clamp-2">{sq.question}</p>
|
||||||
<div className="flex items-center justify-between mt-1.5">
|
<div className="flex items-center justify-between mt-1.5">
|
||||||
<Badge
|
<Badge variant={sq.confidence >= 0.7 ? 'success' : 'secondary'} size="sm">
|
||||||
variant={sq.confidence >= 0.7 ? 'success' : 'secondary'}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{Math.round(sq.confidence * 100)}%
|
{Math.round(sq.confidence * 100)}%
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs text-foreground-tertiary">
|
<span className="text-xs text-foreground-tertiary">
|
||||||
|
|
|
||||||
|
|
@ -110,9 +110,7 @@ export async function generateMetadata({ params }: QuestionPageProps): Promise<M
|
||||||
}
|
}
|
||||||
|
|
||||||
const truncatedQuestion =
|
const truncatedQuestion =
|
||||||
question.question.length > 60
|
question.question.length > 60 ? `${question.question.slice(0, 60)}...` : question.question;
|
||||||
? question.question.slice(0, 60) + '...'
|
|
||||||
: question.question;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${truncatedQuestion} | TPMJS Skills`,
|
title: `${truncatedQuestion} | TPMJS Skills`,
|
||||||
|
|
|
||||||
|
|
@ -87,32 +87,36 @@ export async function GET(_request: NextRequest, context: RouteContext) {
|
||||||
|
|
||||||
// Check if collection is public
|
// Check if collection is public
|
||||||
if (!question.collection.isPublic) {
|
if (!question.collection.isPublic) {
|
||||||
return NextResponse.json({ error: 'Question belongs to a private collection' }, { status: 403 });
|
return NextResponse.json(
|
||||||
|
{ error: 'Question belongs to a private collection' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch similar questions (based on same skills)
|
// Fetch similar questions (based on same skills)
|
||||||
const skillIds = question.skillNodes.map((sn) => sn.skill.id);
|
const skillIds = question.skillNodes.map((sn) => sn.skill.id);
|
||||||
const similarQuestions = skillIds.length > 0
|
const similarQuestions =
|
||||||
? await prisma.skillQuestion.findMany({
|
skillIds.length > 0
|
||||||
where: {
|
? await prisma.skillQuestion.findMany({
|
||||||
id: { not: id },
|
where: {
|
||||||
collectionId: question.collection.id,
|
id: { not: id },
|
||||||
skillNodes: {
|
collectionId: question.collection.id,
|
||||||
some: {
|
skillNodes: {
|
||||||
skillId: { in: skillIds },
|
some: {
|
||||||
|
skillId: { in: skillIds },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
take: 5,
|
||||||
take: 5,
|
orderBy: { createdAt: 'desc' },
|
||||||
orderBy: { createdAt: 'desc' },
|
select: {
|
||||||
select: {
|
id: true,
|
||||||
id: true,
|
question: true,
|
||||||
question: true,
|
confidence: true,
|
||||||
confidence: true,
|
createdAt: true,
|
||||||
createdAt: true,
|
},
|
||||||
},
|
})
|
||||||
})
|
: [];
|
||||||
: [];
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -1,370 +1,46 @@
|
||||||
'use client';
|
import { prisma } from '@tpmjs/db';
|
||||||
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
export const dynamic = 'force-dynamic';
|
||||||
import { Button } from '@tpmjs/ui/Button/Button';
|
|
||||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { AppHeader } from '~/components/AppHeader';
|
|
||||||
import { LikeButton } from '~/components/LikeButton';
|
|
||||||
|
|
||||||
interface CollectionTool {
|
interface CollectionRedirectPageProps {
|
||||||
id: string;
|
params: Promise<{ id: string }>;
|
||||||
toolId: string;
|
|
||||||
position: number;
|
|
||||||
note: string | null;
|
|
||||||
addedAt: string;
|
|
||||||
tool: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
likeCount: number;
|
|
||||||
package: {
|
|
||||||
id: string;
|
|
||||||
npmPackageName: string;
|
|
||||||
category: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicCollection {
|
/**
|
||||||
id: string;
|
* DEPRECATED: This route is deprecated in favor of /@username/collections/[slug]
|
||||||
slug: string | null;
|
* All requests are 301 redirected to the new canonical URL.
|
||||||
name: string;
|
*/
|
||||||
description: string | null;
|
export default async function CollectionRedirectPage({ params }: CollectionRedirectPageProps) {
|
||||||
likeCount: number;
|
const { id } = await params;
|
||||||
toolCount: number;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
createdBy: {
|
|
||||||
id: string;
|
|
||||||
username: string | null;
|
|
||||||
name: string;
|
|
||||||
image: string | null;
|
|
||||||
};
|
|
||||||
tools: CollectionTool[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function McpUrlSection({ username, slug }: { username: string; slug: string }) {
|
// Look up the collection by ID
|
||||||
const [copiedUrl, setCopiedUrl] = useState<'http' | 'sse' | null>(null);
|
const collection = await prisma.collection.findUnique({
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
where: { id },
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
isPublic: true,
|
||||||
|
user: {
|
||||||
|
select: { username: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
|
// If collection doesn't exist, return 404
|
||||||
const httpUrl = `${baseUrl}/api/mcp/${username}/${slug}/http`;
|
if (!collection) {
|
||||||
const sseUrl = `${baseUrl}/api/mcp/${username}/${slug}/sse`;
|
notFound();
|
||||||
|
|
||||||
const copyToClipboard = async (url: string, type: 'http' | 'sse') => {
|
|
||||||
await navigator.clipboard.writeText(url);
|
|
||||||
setCopiedUrl(type);
|
|
||||||
setTimeout(() => setCopiedUrl(null), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const configSnippet = `{
|
|
||||||
"mcpServers": {
|
|
||||||
"tpmjs-collection": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"mcp-remote",
|
|
||||||
"${httpUrl}",
|
|
||||||
"--header",
|
|
||||||
"Authorization: Bearer YOUR_TPMJS_API_KEY"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-8 p-4 bg-gradient-to-br from-primary/5 via-transparent to-primary/5 border border-primary/20 rounded-xl">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<div className="p-1.5 bg-primary/10 rounded-lg">
|
|
||||||
<Icon icon="link" size="sm" className="text-primary" />
|
|
||||||
</div>
|
|
||||||
<h3 className="font-semibold text-foreground">MCP Server URLs</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* HTTP Transport */}
|
|
||||||
<div className="group">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
|
|
||||||
HTTP Transport
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-foreground-tertiary">(recommended)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
|
|
||||||
{httpUrl}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => copyToClipboard(httpUrl, 'http')}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
<Icon icon={copiedUrl === 'http' ? 'check' : 'copy'} size="xs" className="mr-1" />
|
|
||||||
{copiedUrl === 'http' ? 'Copied!' : 'Copy'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SSE Transport */}
|
|
||||||
<div className="group">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
|
|
||||||
SSE Transport
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-foreground-tertiary">(streaming)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
|
|
||||||
{sseUrl}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => copyToClipboard(sseUrl, 'sse')}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
<Icon icon={copiedUrl === 'sse' ? 'check' : 'copy'} size="xs" className="mr-1" />
|
|
||||||
{copiedUrl === 'sse' ? 'Copied!' : 'Copy'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Config snippet toggle */}
|
|
||||||
<div className="mt-4 pt-4 border-t border-border/50">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowConfig(!showConfig)}
|
|
||||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
|
||||||
>
|
|
||||||
<Icon icon={showConfig ? 'chevronDown' : 'chevronRight'} size="xs" />
|
|
||||||
<span>Show Claude Desktop config</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showConfig && (
|
|
||||||
<div className="mt-3 relative">
|
|
||||||
<pre className="p-4 bg-surface border border-border rounded-lg text-xs font-mono text-foreground-secondary overflow-x-auto">
|
|
||||||
{configSnippet}
|
|
||||||
</pre>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(configSnippet);
|
|
||||||
setCopiedUrl('http');
|
|
||||||
setTimeout(() => setCopiedUrl(null), 2000);
|
|
||||||
}}
|
|
||||||
className="absolute top-2 right-2"
|
|
||||||
>
|
|
||||||
<Icon icon="copy" size="xs" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-3 text-xs text-foreground-tertiary">
|
|
||||||
Use these URLs with{' '}
|
|
||||||
<Link href="/docs/tutorials/mcp" className="text-primary hover:underline">
|
|
||||||
Claude Desktop, Cursor, or any MCP client
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PublicCollectionDetailPage(): React.ReactElement {
|
|
||||||
const params = useParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const collectionId = params.id as string;
|
|
||||||
|
|
||||||
const [collection, setCollection] = useState<PublicCollection | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(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 (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
<AppHeader />
|
|
||||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="h-8 bg-surface-secondary rounded w-1/2 mb-4" />
|
|
||||||
<div className="h-4 bg-surface-secondary rounded w-full mb-8" />
|
|
||||||
<div className="h-32 bg-surface-secondary rounded mb-8" />
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="h-24 bg-surface-secondary rounded" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !collection) {
|
// If collection is private, return 404 (don't reveal existence)
|
||||||
return (
|
if (!collection.isPublic) {
|
||||||
<div className="min-h-screen bg-background">
|
notFound();
|
||||||
<AppHeader />
|
|
||||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
|
||||||
<div className="text-center">
|
|
||||||
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
|
|
||||||
<h2 className="text-lg font-medium text-foreground mb-2">
|
|
||||||
{error || 'Collection not found'}
|
|
||||||
</h2>
|
|
||||||
<p className="text-foreground-secondary mb-4">
|
|
||||||
This collection may be private or no longer available.
|
|
||||||
</p>
|
|
||||||
<Link href="/collections">
|
|
||||||
<Button>Browse Collections</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// If user has no username or collection has no slug, can't redirect to pretty URL
|
||||||
<div className="min-h-screen bg-background">
|
if (!collection.user.username || !collection.slug) {
|
||||||
<AppHeader />
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
// 301 permanent redirect to the canonical URL
|
||||||
{/* Back link */}
|
redirect(`/@${collection.user.username}/collections/${collection.slug}`);
|
||||||
<Link
|
|
||||||
href="/collections"
|
|
||||||
className="inline-flex items-center gap-1 text-sm text-foreground-secondary hover:text-foreground mb-6"
|
|
||||||
>
|
|
||||||
<Icon icon="arrowLeft" size="xs" />
|
|
||||||
Back to Collections
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-2">{collection.name}</h1>
|
|
||||||
{collection.description && (
|
|
||||||
<p className="text-foreground-secondary">{collection.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<LikeButton
|
|
||||||
entityType="collection"
|
|
||||||
entityId={collection.id}
|
|
||||||
initialCount={collection.likeCount}
|
|
||||||
showCount={true}
|
|
||||||
variant="outline"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Meta info */}
|
|
||||||
<div className="flex items-center gap-4 mb-8 text-sm text-foreground-tertiary">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{collection.createdBy.image ? (
|
|
||||||
<img
|
|
||||||
src={collection.createdBy.image}
|
|
||||||
alt={collection.createdBy.name}
|
|
||||||
className="w-6 h-6 rounded-full"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
|
|
||||||
<Icon icon="user" size="xs" className="text-primary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span>Created by {collection.createdBy.name}</span>
|
|
||||||
</div>
|
|
||||||
<span>•</span>
|
|
||||||
<span>
|
|
||||||
{collection.toolCount} tool{collection.toolCount !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* MCP URLs */}
|
|
||||||
{collection.createdBy?.username && collection.slug && (
|
|
||||||
<McpUrlSection username={collection.createdBy.username} slug={collection.slug} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tools */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground mb-4">Tools in this Collection</h2>
|
|
||||||
|
|
||||||
{collection.tools.length === 0 ? (
|
|
||||||
<div className="text-center py-12 bg-surface border border-border rounded-lg">
|
|
||||||
<Icon icon="puzzle" size="lg" className="mx-auto text-foreground-tertiary mb-2" />
|
|
||||||
<p className="text-foreground-secondary">No tools in this collection yet</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{collection.tools.map((ct) => (
|
|
||||||
<div
|
|
||||||
key={ct.id}
|
|
||||||
className="bg-surface border border-border rounded-lg p-4 hover:border-foreground/20 hover:shadow-sm transition-all"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
href={`/tool/${ct.tool.package.npmPackageName}/${ct.tool.name}`}
|
|
||||||
className="font-medium text-foreground hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{ct.tool.name}
|
|
||||||
</Link>
|
|
||||||
<span className="text-sm text-foreground-tertiary ml-2">
|
|
||||||
from {ct.tool.package.npmPackageName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<LikeButton
|
|
||||||
entityType="tool"
|
|
||||||
entityId={ct.tool.id}
|
|
||||||
initialCount={ct.tool.likeCount}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-foreground-secondary line-clamp-2 mb-2">
|
|
||||||
{ct.tool.description}
|
|
||||||
</p>
|
|
||||||
<Badge variant="secondary" size="sm">
|
|
||||||
{ct.tool.package.category}
|
|
||||||
</Badge>
|
|
||||||
{ct.note && (
|
|
||||||
<p className="mt-2 text-xs text-foreground-tertiary italic">Note: {ct.note}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
* DEPRECATED: The /collections list page is deprecated.
|
||||||
import { ErrorState } from '@tpmjs/ui/ErrorState/ErrorState';
|
* Users should browse collections through user profiles.
|
||||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
* All requests are 301 redirected to the homepage.
|
||||||
import { Input } from '@tpmjs/ui/Input/Input';
|
*/
|
||||||
import { LoadingState } from '@tpmjs/ui/LoadingState/LoadingState';
|
export default function CollectionsListRedirectPage() {
|
||||||
import { PageHeader } from '@tpmjs/ui/PageHeader/PageHeader';
|
redirect('/');
|
||||||
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<PublicCollection[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [hasMore, setHasMore] = useState(false);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [sort, setSort] = useState<SortOption>('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(
|
|
||||||
() => (
|
|
||||||
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
|
|
||||||
<th className="px-4 py-3 w-[250px]">Name</th>
|
|
||||||
<th className="px-4 py-3 w-[300px]">Description</th>
|
|
||||||
<th className="px-4 py-3 w-[80px] text-center">Tools</th>
|
|
||||||
<th className="px-4 py-3 w-[80px] text-center">Likes</th>
|
|
||||||
<th className="px-4 py-3 w-[150px]">Creator</th>
|
|
||||||
<th className="px-4 py-3 w-[100px] text-right">Copy</th>
|
|
||||||
</tr>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const TableRow = useCallback((_index: number, collection: PublicCollection) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Link
|
|
||||||
href={collection.createdBy.username ? `/${collection.createdBy.username}/collections/${collection.slug}` : `/collections/${collection.id}`}
|
|
||||||
className="font-semibold text-foreground hover:text-primary group-hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{collection.name}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-foreground-secondary">
|
|
||||||
{collection.description ? truncateText(collection.description, 60) : '—'}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-center">
|
|
||||||
<Badge variant="secondary" size="sm">
|
|
||||||
{collection.toolCount}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-center">
|
|
||||||
<LikeButton
|
|
||||||
entityType="collection"
|
|
||||||
entityId={collection.id}
|
|
||||||
initialCount={collection.likeCount}
|
|
||||||
size="sm"
|
|
||||||
showCount={true}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{collection.createdBy.image ? (
|
|
||||||
<img
|
|
||||||
src={collection.createdBy.image}
|
|
||||||
alt={collection.createdBy.name}
|
|
||||||
className="w-5 h-5 rounded-full"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center">
|
|
||||||
<Icon icon="user" size="xs" className="text-primary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="text-sm text-foreground-secondary truncate max-w-[100px]">
|
|
||||||
{collection.createdBy.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
{collection.createdBy.username && (
|
|
||||||
<CopyDropdown
|
|
||||||
options={getCollectionCopyOptions(
|
|
||||||
collection.createdBy.username,
|
|
||||||
collection.slug,
|
|
||||||
collection.name
|
|
||||||
)}
|
|
||||||
buttonLabel="Copy"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
<AppHeader />
|
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<PageHeader
|
|
||||||
title="Public Collections"
|
|
||||||
description="Discover curated tool collections shared by the community"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
placeholder="Search collections..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-foreground-secondary">Sort:</span>
|
|
||||||
<Select
|
|
||||||
value={sort}
|
|
||||||
onChange={(e) => setSort(e.target.value as SortOption)}
|
|
||||||
options={[
|
|
||||||
{ value: 'likes', label: 'Most Liked' },
|
|
||||||
{ value: 'recent', label: 'Most Recent' },
|
|
||||||
{ value: 'tools', label: 'Most Tools' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{error ? (
|
|
||||||
<ErrorState message={error} onRetry={() => fetchCollections(0, true)} />
|
|
||||||
) : isLoading ? (
|
|
||||||
<LoadingState message="Loading collections..." size="lg" />
|
|
||||||
) : filteredCollections.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon="folder"
|
|
||||||
title="No collections found"
|
|
||||||
description={
|
|
||||||
search
|
|
||||||
? 'Try adjusting your search terms'
|
|
||||||
: 'Be the first to share a public collection!'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="border border-border rounded-lg overflow-hidden">
|
|
||||||
<TableVirtuoso
|
|
||||||
style={{ height: 'calc(100vh - 350px)', minHeight: '400px' }}
|
|
||||||
data={filteredCollections}
|
|
||||||
overscan={30}
|
|
||||||
endReached={loadMore}
|
|
||||||
fixedHeaderContent={TableHeader}
|
|
||||||
itemContent={TableRow}
|
|
||||||
components={{
|
|
||||||
Table: (props) => (
|
|
||||||
<table
|
|
||||||
{...props}
|
|
||||||
className="w-full border-collapse text-sm"
|
|
||||||
style={{ tableLayout: 'fixed' }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
TableHead: (props) => (
|
|
||||||
<thead {...props} className="bg-surface-secondary sticky top-0 z-10" />
|
|
||||||
),
|
|
||||||
TableBody: (props) => <tbody {...props} />,
|
|
||||||
TableRow: (props) => (
|
|
||||||
<tr
|
|
||||||
{...props}
|
|
||||||
className="border-b border-border bg-surface hover:bg-surface-secondary transition-all duration-150 group"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 text-sm text-foreground-tertiary">
|
|
||||||
Showing {filteredCollections.length} collection
|
|
||||||
{filteredCollections.length !== 1 ? 's' : ''}
|
|
||||||
{search && ` matching "${search}"`}
|
|
||||||
{hasMore && ' (scroll for more)'}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,41 +19,11 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { AddToolSearch } from '~/components/collections/AddToolSearch';
|
import { AddToolSearch } from '~/components/collections/AddToolSearch';
|
||||||
import { CollectionForm } from '~/components/collections/CollectionForm';
|
import { CollectionForm } from '~/components/collections/CollectionForm';
|
||||||
|
import { InstallationSection } from '~/components/collections/InstallationSection';
|
||||||
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
|
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
|
||||||
import { EnvVarsEditor } from '~/components/EnvVarsEditor';
|
import { EnvVarsEditor } from '~/components/EnvVarsEditor';
|
||||||
import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel';
|
import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel';
|
||||||
|
|
||||||
// MCP URL display component
|
|
||||||
function McpUrlDisplay({ url, label, sublabel }: { url: string; label: string; sublabel: string }) {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const copyToClipboard = async () => {
|
|
||||||
await navigator.clipboard.writeText(url);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-foreground-tertiary">({sublabel})</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
|
|
||||||
{url}
|
|
||||||
</div>
|
|
||||||
<Button variant="secondary" size="sm" onClick={copyToClipboard} className="shrink-0">
|
|
||||||
<Icon icon={copied ? 'check' : 'copy'} size="xs" className="mr-1" />
|
|
||||||
{copied ? 'Copied!' : 'Copy'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CollectionTool {
|
interface CollectionTool {
|
||||||
id: string;
|
id: string;
|
||||||
toolId: string;
|
toolId: string;
|
||||||
|
|
@ -91,9 +61,9 @@ interface Collection {
|
||||||
tools: CollectionTool[];
|
tools: CollectionTool[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabId = 'tools' | 'connect' | 'env-vars' | 'settings';
|
type TabId = 'tools' | 'installation' | 'env-vars' | 'settings';
|
||||||
|
|
||||||
const VALID_TABS: TabId[] = ['tools', 'connect', 'env-vars', 'settings'];
|
const VALID_TABS: TabId[] = ['tools', 'installation', 'env-vars', 'settings'];
|
||||||
|
|
||||||
export default function CollectionDetailPage(): React.ReactElement {
|
export default function CollectionDetailPage(): React.ReactElement {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
@ -114,7 +84,6 @@ export default function CollectionDetailPage(): React.ReactElement {
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [executorConfig, setExecutorConfig] = useState<ExecutorConfig | null>(null);
|
const [executorConfig, setExecutorConfig] = useState<ExecutorConfig | null>(null);
|
||||||
const [envVars, setEnvVars] = useState<Record<string, string> | null>(null);
|
const [envVars, setEnvVars] = useState<Record<string, string> | null>(null);
|
||||||
const [showClaudeConfig, setShowClaudeConfig] = useState(false);
|
|
||||||
|
|
||||||
// Update URL when tab changes
|
// Update URL when tab changes
|
||||||
const handleTabChange = (tabId: string) => {
|
const handleTabChange = (tabId: string) => {
|
||||||
|
|
@ -365,29 +334,11 @@ export default function CollectionDetailPage(): React.ReactElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingToolIds = collection.tools.map((t) => t.toolId);
|
const existingToolIds = collection.tools.map((t) => t.toolId);
|
||||||
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
|
|
||||||
const httpUrl = `${baseUrl}/api/mcp/${collection.user.username}/${collection.slug}/http`;
|
|
||||||
const sseUrl = `${baseUrl}/api/mcp/${collection.user.username}/${collection.slug}/sse`;
|
|
||||||
|
|
||||||
const configSnippet = `{
|
|
||||||
"mcpServers": {
|
|
||||||
"${collection.slug}": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"mcp-remote",
|
|
||||||
"${httpUrl}",
|
|
||||||
"--header",
|
|
||||||
"Authorization: Bearer YOUR_TPMJS_API_KEY"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const envVarsCount = envVars ? Object.keys(envVars).length : 0;
|
const envVarsCount = envVars ? Object.keys(envVars).length : 0;
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'tools' as const, label: 'Tools', count: collection.toolCount },
|
{ id: 'tools' as const, label: 'Tools', count: collection.toolCount },
|
||||||
{ id: 'connect' as const, label: 'Connect' },
|
{ id: 'installation' as const, label: 'Installation' },
|
||||||
{
|
{
|
||||||
id: 'env-vars' as const,
|
id: 'env-vars' as const,
|
||||||
label: 'Env Vars',
|
label: 'Env Vars',
|
||||||
|
|
@ -518,11 +469,11 @@ export default function CollectionDetailPage(): React.ReactElement {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Connect Tab */}
|
{/* Installation Tab */}
|
||||||
{activeTab === 'connect' && (
|
{activeTab === 'installation' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Username warning */}
|
{/* Username warning */}
|
||||||
{collection.isPublic && !collection.user.username && (
|
{!collection.user.username && (
|
||||||
<div className="p-4 bg-warning/10 border border-warning/30 rounded-lg">
|
<div className="p-4 bg-warning/10 border border-warning/30 rounded-lg">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Icon icon="alertCircle" size="sm" className="text-warning mt-0.5" />
|
<Icon icon="alertCircle" size="sm" className="text-warning mt-0.5" />
|
||||||
|
|
@ -558,63 +509,20 @@ export default function CollectionDetailPage(): React.ReactElement {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* MCP URLs */}
|
{/* Installation Section */}
|
||||||
{collection.isPublic && collection.user.username && (
|
{collection.user.username && (
|
||||||
<div className="bg-surface border border-border rounded-lg p-6">
|
<InstallationSection
|
||||||
<div className="flex items-center gap-2 mb-4">
|
collection={{
|
||||||
<div className="p-1.5 bg-primary/10 rounded-lg">
|
id: collection.id,
|
||||||
<Icon icon="link" size="sm" className="text-primary" />
|
slug: collection.slug,
|
||||||
</div>
|
name: collection.name,
|
||||||
<h3 className="font-semibold text-foreground">MCP Server URLs</h3>
|
toolCount: collection.toolCount,
|
||||||
</div>
|
envVars: envVars,
|
||||||
|
}}
|
||||||
<div className="space-y-4">
|
username={collection.user.username}
|
||||||
<McpUrlDisplay url={httpUrl} label="HTTP Transport" sublabel="recommended" />
|
isPrivate={!collection.isPublic}
|
||||||
<McpUrlDisplay url={sseUrl} label="SSE Transport" sublabel="streaming" />
|
showForkButton={false}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="mt-6 pt-4 border-t border-border">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowClaudeConfig(!showClaudeConfig)}
|
|
||||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
|
||||||
>
|
|
||||||
<Icon icon={showClaudeConfig ? 'chevronDown' : 'chevronRight'} size="xs" />
|
|
||||||
<span>Show Claude Desktop config</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showClaudeConfig && (
|
|
||||||
<div className="mt-3 relative">
|
|
||||||
<pre className="p-4 bg-surface-secondary border border-border rounded-lg text-xs font-mono text-foreground-secondary overflow-x-auto">
|
|
||||||
{configSnippet}
|
|
||||||
</pre>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigator.clipboard.writeText(configSnippet)}
|
|
||||||
className="absolute top-2 right-2"
|
|
||||||
>
|
|
||||||
<Icon icon="copy" size="xs" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-4 text-xs text-foreground-tertiary">
|
|
||||||
Use these URLs with{' '}
|
|
||||||
<Link href="/docs/tutorials/mcp" className="text-primary hover:underline">
|
|
||||||
Claude Desktop, Cursor, or any MCP client
|
|
||||||
</Link>
|
|
||||||
. Requires your{' '}
|
|
||||||
<Link
|
|
||||||
href="/dashboard/settings/tpmjs-api-keys"
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
>
|
|
||||||
TPMJS API key
|
|
||||||
</Link>{' '}
|
|
||||||
for authentication.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ export default function LikedCollectionsPage(): React.ReactElement {
|
||||||
<p className="text-foreground-secondary mb-4">
|
<p className="text-foreground-secondary mb-4">
|
||||||
Browse public collections and click the heart icon to save your favorites
|
Browse public collections and click the heart icon to save your favorites
|
||||||
</p>
|
</p>
|
||||||
<Link href="/collections">
|
<Link href="/">
|
||||||
<Button>Browse Collections</Button>
|
<Button>Browse Collections</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -213,11 +213,6 @@ export function AppHeader(): React.ReactElement {
|
||||||
Tools
|
Tools
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/collections">
|
|
||||||
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
|
||||||
Collections
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Link href="/agents">
|
<Link href="/agents">
|
||||||
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
||||||
Agents
|
Agents
|
||||||
|
|
|
||||||
301
apps/web/src/components/collections/InstallationSection.tsx
Normal file
301
apps/web/src/components/collections/InstallationSection.tsx
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
'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 { ForkButton } from '~/components/ForkButton';
|
||||||
|
|
||||||
|
interface EnvVar {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstallationSectionProps {
|
||||||
|
collection: {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
toolCount: number;
|
||||||
|
envVars?: Record<string, string> | null;
|
||||||
|
};
|
||||||
|
username: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
/** Whether to show the fork button (typically false for dashboard/owner view) */
|
||||||
|
showForkButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InstallationSection({
|
||||||
|
collection,
|
||||||
|
username,
|
||||||
|
isPrivate,
|
||||||
|
showForkButton = true,
|
||||||
|
}: InstallationSectionProps) {
|
||||||
|
const [copiedCommand, setCopiedCommand] = useState(false);
|
||||||
|
const [showClaudeDesktop, setShowClaudeDesktop] = useState(false);
|
||||||
|
const [showTroubleshooting, setShowTroubleshooting] = useState(false);
|
||||||
|
|
||||||
|
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
|
||||||
|
const mcpUrl = `${baseUrl}/@${username}/collections/${collection.slug}/mcp`;
|
||||||
|
|
||||||
|
// Build the claude mcp add command
|
||||||
|
const commandParts = ['claude mcp add', collection.slug, '--transport http', mcpUrl];
|
||||||
|
|
||||||
|
if (isPrivate) {
|
||||||
|
commandParts.push('--header "Authorization: Bearer YOUR_API_KEY"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const installCommand = commandParts.join(' \\\n ');
|
||||||
|
|
||||||
|
// Build Claude Desktop config JSON
|
||||||
|
const claudeDesktopConfig = isPrivate
|
||||||
|
? JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
[collection.slug]: {
|
||||||
|
type: 'http',
|
||||||
|
url: mcpUrl,
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer YOUR_API_KEY',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
: JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
[collection.slug]: {
|
||||||
|
type: 'http',
|
||||||
|
url: mcpUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract env var names from collection
|
||||||
|
const envVarsList: EnvVar[] = collection.envVars
|
||||||
|
? Object.keys(collection.envVars).map((name) => ({ name }))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const copyCommand = async () => {
|
||||||
|
// Copy the flat command (without line breaks for easy pasting)
|
||||||
|
const flatCommand = isPrivate
|
||||||
|
? `claude mcp add ${collection.slug} --transport http ${mcpUrl} --header "Authorization: Bearer YOUR_API_KEY"`
|
||||||
|
: `claude mcp add ${collection.slug} --transport http ${mcpUrl}`;
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(flatCommand);
|
||||||
|
setCopiedCommand(true);
|
||||||
|
setTimeout(() => setCopiedCommand(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-surface border border-border rounded-lg p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Installation</h2>
|
||||||
|
<Badge variant="secondary" size="sm">
|
||||||
|
{collection.toolCount} {collection.toolCount === 1 ? 'tool' : 'tools'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Env Vars Warning */}
|
||||||
|
{envVarsList.length > 0 && (
|
||||||
|
<div className="mb-6 p-4 bg-warning/10 border border-warning/30 rounded-lg">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Icon icon="alertTriangle" size="sm" className="text-warning mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-foreground">Required Environment Variables</h3>
|
||||||
|
<p className="text-sm text-foreground-secondary mt-1">
|
||||||
|
Set these before tools will work:
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{envVarsList.map((envVar) => (
|
||||||
|
<li key={envVar.name} className="text-sm">
|
||||||
|
<code className="font-mono text-xs bg-surface px-1.5 py-0.5 rounded border border-border">
|
||||||
|
{envVar.name}
|
||||||
|
</code>
|
||||||
|
{envVar.description && (
|
||||||
|
<span className="text-foreground-tertiary ml-2">— {envVar.description}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 1: Add to Claude */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-sm font-medium text-foreground mb-2">Step 1: Add to Claude</h3>
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="p-4 bg-surface-secondary border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
|
{installCommand}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={copyCommand}
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
>
|
||||||
|
<Icon icon={copiedCommand ? 'check' : 'copy'} size="xs" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isPrivate && (
|
||||||
|
<p className="mt-2 text-xs text-foreground-tertiary">
|
||||||
|
Get your API key from{' '}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/tpmjs-api-keys"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2: Verify */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-sm font-medium text-foreground mb-2">Step 2: Verify</h3>
|
||||||
|
<p className="text-sm text-foreground-secondary">
|
||||||
|
Run{' '}
|
||||||
|
<code className="font-mono text-xs bg-surface-secondary px-1.5 py-0.5 rounded border border-border">
|
||||||
|
/mcp
|
||||||
|
</code>{' '}
|
||||||
|
in Claude Code to confirm the server is connected.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fork Button */}
|
||||||
|
{showForkButton && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<ForkButton
|
||||||
|
type="collection"
|
||||||
|
sourceId={collection.id}
|
||||||
|
sourceName={collection.name}
|
||||||
|
variant="full"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collapsible Sections */}
|
||||||
|
<div className="pt-4 border-t border-border space-y-2">
|
||||||
|
{/* Claude Desktop */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowClaudeDesktop(!showClaudeDesktop)}
|
||||||
|
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon icon={showClaudeDesktop ? 'chevronDown' : 'chevronRight'} size="xs" />
|
||||||
|
<span>Using Claude Desktop instead?</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showClaudeDesktop && (
|
||||||
|
<div className="mt-3 ml-6">
|
||||||
|
<p className="text-sm text-foreground-secondary mb-2">
|
||||||
|
Add this to your{' '}
|
||||||
|
<code className="font-mono text-xs bg-surface-secondary px-1.5 py-0.5 rounded border border-border">
|
||||||
|
claude_desktop_config.json
|
||||||
|
</code>{' '}
|
||||||
|
file:
|
||||||
|
</p>
|
||||||
|
<CodeBlock language="json" code={claudeDesktopConfig} />
|
||||||
|
{isPrivate && (
|
||||||
|
<p className="mt-2 text-xs text-foreground-tertiary">
|
||||||
|
Replace <code className="font-mono">YOUR_API_KEY</code> with your{' '}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/tpmjs-api-keys"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
TPMJS API key
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Troubleshooting */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowTroubleshooting(!showTroubleshooting)}
|
||||||
|
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon icon={showTroubleshooting ? 'chevronDown' : 'chevronRight'} size="xs" />
|
||||||
|
<span>Troubleshooting</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showTroubleshooting && (
|
||||||
|
<div className="mt-3 ml-6 space-y-4 text-sm">
|
||||||
|
{/* Connection timeout */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-foreground">Connection timeout</h4>
|
||||||
|
<ul className="mt-1 text-foreground-secondary space-y-1">
|
||||||
|
<li>
|
||||||
|
• Try increasing the timeout:{' '}
|
||||||
|
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
|
||||||
|
MCP_TIMEOUT=10000 claude
|
||||||
|
</code>
|
||||||
|
</li>
|
||||||
|
<li>• Check firewall/VPN settings</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auth failures */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-foreground">Authentication failures</h4>
|
||||||
|
<ul className="mt-1 text-foreground-secondary space-y-1">
|
||||||
|
<li>• Verify your API key is correct</li>
|
||||||
|
<li>
|
||||||
|
• Ensure the header format is{' '}
|
||||||
|
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
|
||||||
|
Authorization: Bearer YOUR_KEY
|
||||||
|
</code>
|
||||||
|
</li>
|
||||||
|
<li>• Check that your API key hasn't expired</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server not appearing */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-foreground">Server not appearing</h4>
|
||||||
|
<ul className="mt-1 text-foreground-secondary space-y-1">
|
||||||
|
<li>
|
||||||
|
• Run{' '}
|
||||||
|
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
|
||||||
|
claude mcp list
|
||||||
|
</code>{' '}
|
||||||
|
to see configured servers
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• Try removing and re-adding:{' '}
|
||||||
|
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
|
||||||
|
claude mcp remove {collection.slug}
|
||||||
|
</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tools not loading */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-foreground">Tools not loading</h4>
|
||||||
|
<ul className="mt-1 text-foreground-secondary space-y-1">
|
||||||
|
<li>• The server may take a moment to initialize on first connection</li>
|
||||||
|
<li>• Check that required environment variables are set</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -124,7 +124,10 @@ export function SkillsActivityFeed({
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{questions.map((q) => (
|
{questions.map((q) => (
|
||||||
<Link key={q.id} href={`${basePath}/skills/questions/${q.id}`} className="block">
|
<Link key={q.id} href={`${basePath}/skills/questions/${q.id}`} className="block">
|
||||||
<Card variant="default" className="hover:border-primary/20 hover:bg-muted/30 transition-all cursor-pointer">
|
<Card
|
||||||
|
variant="default"
|
||||||
|
className="hover:border-primary/20 hover:bg-muted/30 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
<CardHeader padding="sm" className="pb-2">
|
<CardHeader padding="sm" className="pb-2">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<CardTitle as="h4" className="text-sm font-medium line-clamp-2">
|
<CardTitle as="h4" className="text-sm font-medium line-clamp-2">
|
||||||
|
|
@ -146,8 +149,8 @@ export function SkillsActivityFeed({
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-1.5 flex-wrap">
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
{q.skillNodes.slice(0, 2).map((sn, i) => (
|
{q.skillNodes.slice(0, 2).map((sn) => (
|
||||||
<Badge key={i} variant="outline" size="sm">
|
<Badge key={sn.skill.name} variant="outline" size="sm">
|
||||||
{sn.skill.name}
|
{sn.skill.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -120,13 +120,15 @@ export function SkillsStats({ collectionId }: SkillsStatsProps): React.ReactElem
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent padding="sm" className="pt-0">
|
<CardContent padding="sm" className="pt-0">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{stats.topSkills.slice(0, 5).map((skill, i) => (
|
{stats.topSkills.slice(0, 5).map((skill) => (
|
||||||
<div key={i} className="space-y-1">
|
<div key={skill.name} className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Badge variant="outline" size="sm" className="truncate max-w-[140px]">
|
<Badge variant="outline" size="sm" className="truncate max-w-[140px]">
|
||||||
{skill.name}
|
{skill.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs text-foreground-secondary">{skill.questionCount} Q</span>
|
<span className="text-xs text-foreground-secondary">
|
||||||
|
{skill.questionCount} Q
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
value={skill.confidence * 100}
|
value={skill.confidence * 100}
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,9 @@ export const DEFAULT_API_KEY_SCOPES: ApiKeyScope[] = [
|
||||||
* Rate limits by user tier (requests per hour)
|
* Rate limits by user tier (requests per hour)
|
||||||
*/
|
*/
|
||||||
export const RATE_LIMITS_BY_TIER = {
|
export const RATE_LIMITS_BY_TIER = {
|
||||||
FREE: 100,
|
FREE: 1000,
|
||||||
PRO: 1000,
|
PRO: 10000,
|
||||||
ENTERPRISE: 10000,
|
ENTERPRISE: 100000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -93,21 +93,21 @@ export interface RateLimitConfig {
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default rate limit: 100 requests per minute */
|
/** Default rate limit: 1000 requests per minute */
|
||||||
export const DEFAULT_RATE_LIMIT: RateLimitConfig = {
|
export const DEFAULT_RATE_LIMIT: RateLimitConfig = {
|
||||||
limit: 100,
|
limit: 1000,
|
||||||
windowSeconds: 60,
|
windowSeconds: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Strict rate limit for expensive operations: 20 requests per minute */
|
/** Strict rate limit for expensive operations: 200 requests per minute */
|
||||||
export const STRICT_RATE_LIMIT: RateLimitConfig = {
|
export const STRICT_RATE_LIMIT: RateLimitConfig = {
|
||||||
limit: 20,
|
limit: 200,
|
||||||
windowSeconds: 60,
|
windowSeconds: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** AI generation rate limit: 5 requests per hour (expensive AI operations) */
|
/** AI generation rate limit: 50 requests per hour (expensive AI operations) */
|
||||||
export const AI_GENERATION_RATE_LIMIT: RateLimitConfig = {
|
export const AI_GENERATION_RATE_LIMIT: RateLimitConfig = {
|
||||||
limit: 5,
|
limit: 50,
|
||||||
windowSeconds: 3600, // 1 hour
|
windowSeconds: 3600, // 1 hour
|
||||||
prefix: 'ai-gen',
|
prefix: 'ai-gen',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
import { prisma } from '@tpmjs/db';
|
import { prisma } from '@tpmjs/db';
|
||||||
|
|
||||||
const RATE_LIMIT_WINDOW_MS = 3600000; // 1 hour
|
const RATE_LIMIT_WINDOW_MS = 3600000; // 1 hour
|
||||||
const RATE_LIMIT_MAX_REQUESTS = 10; // 10 executions per hour
|
const RATE_LIMIT_MAX_REQUESTS = 100; // 100 executions per hour
|
||||||
|
|
||||||
export interface RateLimitResult {
|
export interface RateLimitResult {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -9941,6 +9941,556 @@ blocks:
|
||||||
description: "Current status of the removal request"
|
description: "Current status of the removal request"
|
||||||
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
# ─── Resend Email API ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ops.resendSendEmail:
|
||||||
|
type: utility
|
||||||
|
description: "Send a single email via the Resend API."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API POST /emails endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: from
|
||||||
|
type: string
|
||||||
|
description: "Sender email address"
|
||||||
|
- name: to
|
||||||
|
type: string | string[]
|
||||||
|
description: "Recipient email address(es)"
|
||||||
|
- name: subject
|
||||||
|
type: string
|
||||||
|
description: "Email subject line"
|
||||||
|
- name: html
|
||||||
|
type: string
|
||||||
|
optional: true
|
||||||
|
description: "HTML body content"
|
||||||
|
- name: text
|
||||||
|
type: string
|
||||||
|
optional: true
|
||||||
|
description: "Plain text body content"
|
||||||
|
outputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "Resend email ID"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendSendBatchEmails:
|
||||||
|
type: utility
|
||||||
|
description: "Send a batch of emails in a single API call via Resend."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API POST /emails/batch endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: emails
|
||||||
|
type: array
|
||||||
|
description: "Array of email objects to send"
|
||||||
|
outputs:
|
||||||
|
- name: data
|
||||||
|
type: array
|
||||||
|
description: "Array of created email IDs"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendGetEmail:
|
||||||
|
type: utility
|
||||||
|
description: "Retrieve details of a sent email by its ID."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API GET /emails/{id} endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "The email ID to retrieve"
|
||||||
|
outputs:
|
||||||
|
- name: result
|
||||||
|
type: object
|
||||||
|
description: "Email details including status and content"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendUpdateEmail:
|
||||||
|
type: utility
|
||||||
|
description: "Update a scheduled email's delivery time."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API PATCH /emails/{id} endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "The email ID to update"
|
||||||
|
- name: scheduled_at
|
||||||
|
type: string
|
||||||
|
description: "New scheduled time in ISO 8601 format"
|
||||||
|
outputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "Updated email ID"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendCancelEmail:
|
||||||
|
type: utility
|
||||||
|
description: "Cancel a scheduled email that has not been sent yet."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API POST /emails/{id}/cancel endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "The email ID to cancel"
|
||||||
|
outputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "Cancelled email ID"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendListEmails:
|
||||||
|
type: utility
|
||||||
|
description: "List sent emails with cursor-based pagination."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API GET /emails endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: limit
|
||||||
|
type: number
|
||||||
|
optional: true
|
||||||
|
description: "Max results to return"
|
||||||
|
- name: after
|
||||||
|
type: string
|
||||||
|
optional: true
|
||||||
|
description: "Cursor for forward pagination"
|
||||||
|
- name: before
|
||||||
|
type: string
|
||||||
|
optional: true
|
||||||
|
description: "Cursor for backward pagination"
|
||||||
|
outputs:
|
||||||
|
- name: data
|
||||||
|
type: array
|
||||||
|
description: "Array of email summaries"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendCreateDomain:
|
||||||
|
type: utility
|
||||||
|
description: "Add a new sending domain to your Resend account."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API POST /domains endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: name
|
||||||
|
type: string
|
||||||
|
description: "Domain name to add"
|
||||||
|
- name: region
|
||||||
|
type: string
|
||||||
|
optional: true
|
||||||
|
description: "Region for the domain"
|
||||||
|
outputs:
|
||||||
|
- name: result
|
||||||
|
type: object
|
||||||
|
description: "Domain details with DNS records"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendGetDomain:
|
||||||
|
type: utility
|
||||||
|
description: "Retrieve details and DNS records for a specific domain."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API GET /domains/{id} endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: domain_id
|
||||||
|
type: string
|
||||||
|
description: "The domain ID to retrieve"
|
||||||
|
outputs:
|
||||||
|
- name: result
|
||||||
|
type: object
|
||||||
|
description: "Domain details including verification status"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendUpdateDomain:
|
||||||
|
type: utility
|
||||||
|
description: "Update tracking and TLS settings for an existing domain."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API PATCH /domains/{id} endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: domain_id
|
||||||
|
type: string
|
||||||
|
description: "The domain ID to update"
|
||||||
|
- name: click_tracking
|
||||||
|
type: boolean
|
||||||
|
optional: true
|
||||||
|
description: "Enable or disable click tracking"
|
||||||
|
outputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "Updated domain ID"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendDeleteDomain:
|
||||||
|
type: utility
|
||||||
|
description: "Delete a sending domain from your Resend account."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API DELETE /domains/{id} endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: domain_id
|
||||||
|
type: string
|
||||||
|
description: "The domain ID to delete"
|
||||||
|
outputs:
|
||||||
|
- name: deleted
|
||||||
|
type: boolean
|
||||||
|
description: "Whether deletion succeeded"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendListDomains:
|
||||||
|
type: utility
|
||||||
|
description: "List all sending domains with their verification status."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API GET /domains endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: limit
|
||||||
|
type: number
|
||||||
|
optional: true
|
||||||
|
description: "Max results to return"
|
||||||
|
outputs:
|
||||||
|
- name: data
|
||||||
|
type: array
|
||||||
|
description: "Array of domain objects"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendVerifyDomain:
|
||||||
|
type: utility
|
||||||
|
description: "Trigger DNS verification for a sending domain."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API POST /domains/{id}/verify endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: domain_id
|
||||||
|
type: string
|
||||||
|
description: "The domain ID to verify"
|
||||||
|
outputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "Verified domain ID"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendCreateApiKey:
|
||||||
|
type: utility
|
||||||
|
description: "Create a new Resend API key."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API POST /api-keys endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: name
|
||||||
|
type: string
|
||||||
|
description: "Name for the API key"
|
||||||
|
- name: permission
|
||||||
|
type: string
|
||||||
|
optional: true
|
||||||
|
description: "Permission level"
|
||||||
|
outputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "Created API key ID"
|
||||||
|
- name: token
|
||||||
|
type: string
|
||||||
|
description: "The API key token"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendListApiKeys:
|
||||||
|
type: utility
|
||||||
|
description: "List all API keys in your Resend account."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API GET /api-keys endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: limit
|
||||||
|
type: number
|
||||||
|
optional: true
|
||||||
|
description: "Max results to return"
|
||||||
|
outputs:
|
||||||
|
- name: data
|
||||||
|
type: array
|
||||||
|
description: "Array of API key objects"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendDeleteApiKey:
|
||||||
|
type: utility
|
||||||
|
description: "Delete an API key from your Resend account."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API DELETE /api-keys/{id} endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: api_key_id
|
||||||
|
type: string
|
||||||
|
description: "The API key ID to delete"
|
||||||
|
outputs:
|
||||||
|
- name: result
|
||||||
|
type: object
|
||||||
|
description: "Deletion confirmation"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendCreateContact:
|
||||||
|
type: utility
|
||||||
|
description: "Create a new contact with email and optional properties."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API POST /contacts endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: email
|
||||||
|
type: string
|
||||||
|
description: "Contact email address"
|
||||||
|
- name: first_name
|
||||||
|
type: string
|
||||||
|
optional: true
|
||||||
|
description: "Contact first name"
|
||||||
|
- name: last_name
|
||||||
|
type: string
|
||||||
|
optional: true
|
||||||
|
description: "Contact last name"
|
||||||
|
outputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "Created contact ID"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendGetContact:
|
||||||
|
type: utility
|
||||||
|
description: "Retrieve a contact by ID or email address."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API GET /contacts/{id} endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "Contact ID or email address"
|
||||||
|
outputs:
|
||||||
|
- name: result
|
||||||
|
type: object
|
||||||
|
description: "Contact details"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendUpdateContact:
|
||||||
|
type: utility
|
||||||
|
description: "Update a contact's name, subscription status, or properties."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API PATCH /contacts/{id} endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "Contact ID or email address"
|
||||||
|
- name: first_name
|
||||||
|
type: string
|
||||||
|
optional: true
|
||||||
|
description: "Updated first name"
|
||||||
|
outputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "Updated contact ID"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendDeleteContact:
|
||||||
|
type: utility
|
||||||
|
description: "Delete a contact by ID or email address."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API DELETE /contacts/{id} endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "Contact ID or email to delete"
|
||||||
|
outputs:
|
||||||
|
- name: deleted
|
||||||
|
type: boolean
|
||||||
|
description: "Whether deletion succeeded"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendListContacts:
|
||||||
|
type: utility
|
||||||
|
description: "List contacts with optional segment filtering and pagination."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API GET /contacts endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: segment_id
|
||||||
|
type: string
|
||||||
|
optional: true
|
||||||
|
description: "Filter by segment ID"
|
||||||
|
- name: limit
|
||||||
|
type: number
|
||||||
|
optional: true
|
||||||
|
description: "Max results to return"
|
||||||
|
outputs:
|
||||||
|
- name: data
|
||||||
|
type: array
|
||||||
|
description: "Array of contact objects"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendCreateBroadcast:
|
||||||
|
type: utility
|
||||||
|
description: "Create a broadcast email draft to send to a segment."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API POST /broadcasts endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: segment_id
|
||||||
|
type: string
|
||||||
|
description: "Segment ID to target"
|
||||||
|
- name: from
|
||||||
|
type: string
|
||||||
|
description: "Sender address"
|
||||||
|
- name: subject
|
||||||
|
type: string
|
||||||
|
description: "Broadcast subject line"
|
||||||
|
outputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "Created broadcast ID"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendListBroadcasts:
|
||||||
|
type: utility
|
||||||
|
description: "List all broadcasts with status and scheduling details."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API GET /broadcasts endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: limit
|
||||||
|
type: number
|
||||||
|
optional: true
|
||||||
|
description: "Max results to return"
|
||||||
|
outputs:
|
||||||
|
- name: data
|
||||||
|
type: array
|
||||||
|
description: "Array of broadcast objects"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendSendBroadcast:
|
||||||
|
type: utility
|
||||||
|
description: "Send or schedule a previously created broadcast."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API POST /broadcasts/{id}/send endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: broadcast_id
|
||||||
|
type: string
|
||||||
|
description: "The broadcast ID to send"
|
||||||
|
- name: scheduled_at
|
||||||
|
type: string
|
||||||
|
optional: true
|
||||||
|
description: "ISO 8601 datetime to schedule"
|
||||||
|
outputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "Sent broadcast ID"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendDeleteBroadcast:
|
||||||
|
type: utility
|
||||||
|
description: "Delete a draft broadcast that has not been sent."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API DELETE /broadcasts/{id} endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: broadcast_id
|
||||||
|
type: string
|
||||||
|
description: "The broadcast ID to delete"
|
||||||
|
outputs:
|
||||||
|
- name: deleted
|
||||||
|
type: boolean
|
||||||
|
description: "Whether deletion succeeded"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendCreateAudience:
|
||||||
|
type: utility
|
||||||
|
description: "Create a new audience for organizing contacts."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API POST /audiences endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: name
|
||||||
|
type: string
|
||||||
|
description: "Audience name"
|
||||||
|
outputs:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
description: "Created audience ID"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendListAudiences:
|
||||||
|
type: utility
|
||||||
|
description: "List all audiences in your Resend account."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API GET /audiences endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: limit
|
||||||
|
type: number
|
||||||
|
optional: true
|
||||||
|
description: "Max results to return"
|
||||||
|
outputs:
|
||||||
|
- name: data
|
||||||
|
type: array
|
||||||
|
description: "Array of audience objects"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendGetAudience:
|
||||||
|
type: utility
|
||||||
|
description: "Retrieve details of a specific audience."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API GET /audiences/{id} endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: audience_id
|
||||||
|
type: string
|
||||||
|
description: "The audience ID to retrieve"
|
||||||
|
outputs:
|
||||||
|
- name: result
|
||||||
|
type: object
|
||||||
|
description: "Audience details"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
|
ops.resendDeleteAudience:
|
||||||
|
type: utility
|
||||||
|
description: "Delete an audience from your Resend account."
|
||||||
|
path: "resend"
|
||||||
|
domain_rules:
|
||||||
|
- id: api_integration
|
||||||
|
description: "Must call Resend API DELETE /audiences/{id} endpoint"
|
||||||
|
inputs:
|
||||||
|
- name: audience_id
|
||||||
|
type: string
|
||||||
|
description: "The audience ID to delete"
|
||||||
|
outputs:
|
||||||
|
- name: deleted
|
||||||
|
type: boolean
|
||||||
|
description: "Whether deletion succeeded"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# VALIDATORS - Which validators to run against each block
|
# VALIDATORS - Which validators to run against each block
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
66
packages/tools/official/resend/block.ts
Normal file
66
packages/tools/official/resend/block.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import {
|
||||||
|
cancelEmail,
|
||||||
|
createApiKey,
|
||||||
|
createAudience,
|
||||||
|
createBroadcast,
|
||||||
|
createContact,
|
||||||
|
createDomain,
|
||||||
|
deleteApiKey,
|
||||||
|
deleteAudience,
|
||||||
|
deleteBroadcast,
|
||||||
|
deleteContact,
|
||||||
|
deleteDomain,
|
||||||
|
getAudience,
|
||||||
|
getContact,
|
||||||
|
getDomain,
|
||||||
|
getEmail,
|
||||||
|
listApiKeys,
|
||||||
|
listAudiences,
|
||||||
|
listBroadcasts,
|
||||||
|
listContacts,
|
||||||
|
listDomains,
|
||||||
|
listEmails,
|
||||||
|
sendBatchEmails,
|
||||||
|
sendBroadcast,
|
||||||
|
sendEmail,
|
||||||
|
updateContact,
|
||||||
|
updateDomain,
|
||||||
|
updateEmail,
|
||||||
|
verifyDomain,
|
||||||
|
} from './src/index.js';
|
||||||
|
|
||||||
|
export const block = {
|
||||||
|
name: 'resend',
|
||||||
|
tools: {
|
||||||
|
sendEmail,
|
||||||
|
sendBatchEmails,
|
||||||
|
getEmail,
|
||||||
|
updateEmail,
|
||||||
|
cancelEmail,
|
||||||
|
listEmails,
|
||||||
|
createDomain,
|
||||||
|
getDomain,
|
||||||
|
updateDomain,
|
||||||
|
deleteDomain,
|
||||||
|
listDomains,
|
||||||
|
verifyDomain,
|
||||||
|
createApiKey,
|
||||||
|
listApiKeys,
|
||||||
|
deleteApiKey,
|
||||||
|
createContact,
|
||||||
|
getContact,
|
||||||
|
updateContact,
|
||||||
|
deleteContact,
|
||||||
|
listContacts,
|
||||||
|
createBroadcast,
|
||||||
|
listBroadcasts,
|
||||||
|
sendBroadcast,
|
||||||
|
deleteBroadcast,
|
||||||
|
createAudience,
|
||||||
|
listAudiences,
|
||||||
|
getAudience,
|
||||||
|
deleteAudience,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default block;
|
||||||
2
packages/tools/official/resend/index.ts
Normal file
2
packages/tools/official/resend/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './src/index.js';
|
||||||
|
export { default } from './src/index.js';
|
||||||
166
packages/tools/official/resend/package.json
Normal file
166
packages/tools/official/resend/package.json
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
{
|
||||||
|
"name": "@tpmjs/tools-resend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Resend email API tools for AI agents. Send emails, manage domains, contacts, broadcasts, audiences, and API keys.",
|
||||||
|
"type": "module",
|
||||||
|
"keywords": [
|
||||||
|
"tpmjs",
|
||||||
|
"resend",
|
||||||
|
"email",
|
||||||
|
"transactional",
|
||||||
|
"agent"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/tpmjs/tpmjs.git",
|
||||||
|
"directory": "packages/tools/official/resend"
|
||||||
|
},
|
||||||
|
"homepage": "https://tpmjs.com",
|
||||||
|
"license": "MIT",
|
||||||
|
"tpmjs": {
|
||||||
|
"category": "ops",
|
||||||
|
"frameworks": [
|
||||||
|
"vercel-ai"
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "sendEmail",
|
||||||
|
"description": "Send a single email via the Resend API."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sendBatchEmails",
|
||||||
|
"description": "Send a batch of emails in a single API call via Resend."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getEmail",
|
||||||
|
"description": "Retrieve details of a specific sent email by ID."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updateEmail",
|
||||||
|
"description": "Update a scheduled email by changing its scheduled time."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cancelEmail",
|
||||||
|
"description": "Cancel a scheduled email that has not been sent yet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listEmails",
|
||||||
|
"description": "List sent emails with optional cursor-based pagination."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "createDomain",
|
||||||
|
"description": "Add a new sending domain to your Resend account."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getDomain",
|
||||||
|
"description": "Retrieve details and DNS records for a specific domain."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updateDomain",
|
||||||
|
"description": "Update tracking and TLS settings for a domain."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deleteDomain",
|
||||||
|
"description": "Delete a sending domain from your Resend account."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listDomains",
|
||||||
|
"description": "List all sending domains in your Resend account."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "verifyDomain",
|
||||||
|
"description": "Trigger DNS verification for a sending domain."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "createApiKey",
|
||||||
|
"description": "Create a new API key with specified permissions."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listApiKeys",
|
||||||
|
"description": "List all API keys in your Resend account."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deleteApiKey",
|
||||||
|
"description": "Delete an API key from your Resend account."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "createContact",
|
||||||
|
"description": "Create a new contact with email and optional properties."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getContact",
|
||||||
|
"description": "Retrieve a contact by their ID or email address."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updateContact",
|
||||||
|
"description": "Update a contact's name, subscription status, or properties."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deleteContact",
|
||||||
|
"description": "Delete a contact by their ID or email address."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listContacts",
|
||||||
|
"description": "List contacts with optional segment filtering and pagination."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "createBroadcast",
|
||||||
|
"description": "Create a new broadcast email to send to a segment."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listBroadcasts",
|
||||||
|
"description": "List all broadcasts with cursor-based pagination."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sendBroadcast",
|
||||||
|
"description": "Send or schedule a previously created broadcast."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deleteBroadcast",
|
||||||
|
"description": "Delete a draft broadcast that has not been sent."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "createAudience",
|
||||||
|
"description": "Create a new audience for organizing contacts."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listAudiences",
|
||||||
|
"description": "List all audiences in your Resend account."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getAudience",
|
||||||
|
"description": "Retrieve details of a specific audience by ID."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deleteAudience",
|
||||||
|
"description": "Delete an audience from your Resend account."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ai": "6.0.49"
|
||||||
|
}
|
||||||
|
}
|
||||||
875
packages/tools/official/resend/src/index.ts
Normal file
875
packages/tools/official/resend/src/index.ts
Normal file
|
|
@ -0,0 +1,875 @@
|
||||||
|
/**
|
||||||
|
* @tpmjs/tools-resend — Resend Email API Tools for AI Agents
|
||||||
|
*
|
||||||
|
* Full access to the Resend email API: send emails, manage domains, contacts,
|
||||||
|
* broadcasts, audiences, and API keys.
|
||||||
|
*
|
||||||
|
* @requires RESEND_API_KEY environment variable
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { jsonSchema, tool } from 'ai';
|
||||||
|
|
||||||
|
const BASE_URL = 'https://api.resend.com';
|
||||||
|
|
||||||
|
// ─── Client Infrastructure ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getApiKey(): string {
|
||||||
|
const key = process.env.RESEND_API_KEY;
|
||||||
|
if (!key) {
|
||||||
|
throw new Error(
|
||||||
|
'RESEND_API_KEY environment variable is required. Get your API key from https://resend.com/api-keys'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiRequest<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const key = getApiKey();
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: RequestInit = { method, headers };
|
||||||
|
if (body !== undefined) {
|
||||||
|
options.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}${path}`, options);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
await handleApiError(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApiError(response: Response): Promise<never> {
|
||||||
|
let errorMessage: string;
|
||||||
|
try {
|
||||||
|
const errorData = (await response.json()) as { message?: string; error?: string };
|
||||||
|
errorMessage = errorData.message || errorData.error || `HTTP ${response.status}`;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (response.status) {
|
||||||
|
case 400:
|
||||||
|
throw new Error(`Bad request: ${errorMessage}`);
|
||||||
|
case 401:
|
||||||
|
throw new Error('Authentication failed: Invalid Resend API key. Check RESEND_API_KEY.');
|
||||||
|
case 403:
|
||||||
|
throw new Error(`Access forbidden: ${errorMessage}`);
|
||||||
|
case 404:
|
||||||
|
throw new Error(`Not found: ${errorMessage}`);
|
||||||
|
case 422:
|
||||||
|
throw new Error(`Validation error: ${errorMessage}`);
|
||||||
|
case 429:
|
||||||
|
throw new Error(`Rate limit exceeded: ${errorMessage}`);
|
||||||
|
default:
|
||||||
|
throw new Error(`Resend API error (${response.status}): ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryString(params: Record<string, unknown>): string {
|
||||||
|
const entries = Object.entries(params).filter(
|
||||||
|
([, v]) => v !== undefined && v !== null && v !== ''
|
||||||
|
);
|
||||||
|
if (entries.length === 0) return '';
|
||||||
|
return (
|
||||||
|
'?' +
|
||||||
|
entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join('&')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Emails ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SendEmailInput {
|
||||||
|
from: string;
|
||||||
|
to: string | string[];
|
||||||
|
subject: string;
|
||||||
|
html?: string;
|
||||||
|
text?: string;
|
||||||
|
cc?: string | string[];
|
||||||
|
bcc?: string | string[];
|
||||||
|
reply_to?: string | string[];
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
attachments?: Array<{ content?: string; filename: string; path?: string; content_type?: string }>;
|
||||||
|
tags?: Array<{ name: string; value: string }>;
|
||||||
|
scheduled_at?: string;
|
||||||
|
topic_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationInput {
|
||||||
|
limit?: number;
|
||||||
|
after?: string;
|
||||||
|
before?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendEmail = tool({
|
||||||
|
description:
|
||||||
|
'Send a single email via the Resend API. Requires from, to, and subject. Provide html and/or text body content.',
|
||||||
|
inputSchema: jsonSchema<SendEmailInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
from: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Sender email address. Supports "Name <email>" format.',
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
oneOf: [
|
||||||
|
{ type: 'string', description: 'Single recipient email address.' },
|
||||||
|
{
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'Array of recipient emails (max 50).',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description: 'Recipient email address(es).',
|
||||||
|
},
|
||||||
|
subject: { type: 'string', description: 'Email subject line.' },
|
||||||
|
html: { type: 'string', description: 'HTML body content.' },
|
||||||
|
text: { type: 'string', description: 'Plain text body content.' },
|
||||||
|
cc: {
|
||||||
|
oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
|
||||||
|
description: 'CC recipient(s).',
|
||||||
|
},
|
||||||
|
bcc: {
|
||||||
|
oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
|
||||||
|
description: 'BCC recipient(s).',
|
||||||
|
},
|
||||||
|
reply_to: {
|
||||||
|
oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
|
||||||
|
description: 'Reply-to address(es).',
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: { type: 'string' },
|
||||||
|
description: 'Custom email headers as key-value pairs.',
|
||||||
|
},
|
||||||
|
attachments: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
content: { type: 'string', description: 'Base64-encoded content.' },
|
||||||
|
filename: { type: 'string', description: 'Attachment filename.' },
|
||||||
|
path: { type: 'string', description: 'URL to fetch attachment from.' },
|
||||||
|
content_type: { type: 'string', description: 'MIME type.' },
|
||||||
|
},
|
||||||
|
required: ['filename'],
|
||||||
|
},
|
||||||
|
description: 'File attachments (max 40MB total).',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', description: 'Tag name.' },
|
||||||
|
value: { type: 'string', description: 'Tag value.' },
|
||||||
|
},
|
||||||
|
required: ['name', 'value'],
|
||||||
|
},
|
||||||
|
description: 'Tags for categorizing emails.',
|
||||||
|
},
|
||||||
|
scheduled_at: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'ISO 8601 datetime or natural language (e.g. "in 1 min").',
|
||||||
|
},
|
||||||
|
topic_id: { type: 'string', description: 'Topic ID for subscription management.' },
|
||||||
|
},
|
||||||
|
required: ['from', 'to', 'subject'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: SendEmailInput) {
|
||||||
|
return apiRequest<{ id: string }>('POST', '/emails', input);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface SendBatchInput {
|
||||||
|
emails: SendEmailInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendBatchEmails = tool({
|
||||||
|
description:
|
||||||
|
'Send a batch of emails in a single API call via Resend. Each email has the same parameters as sendEmail.',
|
||||||
|
inputSchema: jsonSchema<SendBatchInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
emails: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
from: { type: 'string', description: 'Sender email address.' },
|
||||||
|
to: {
|
||||||
|
oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
|
||||||
|
description: 'Recipient email address(es).',
|
||||||
|
},
|
||||||
|
subject: { type: 'string', description: 'Email subject line.' },
|
||||||
|
html: { type: 'string', description: 'HTML body content.' },
|
||||||
|
text: { type: 'string', description: 'Plain text body content.' },
|
||||||
|
},
|
||||||
|
required: ['from', 'to', 'subject'],
|
||||||
|
},
|
||||||
|
description: 'Array of email objects to send.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['emails'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: SendBatchInput) {
|
||||||
|
return apiRequest<{ data: Array<{ id: string }> }>('POST', '/emails/batch', input.emails);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface IdInput {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getEmail = tool({
|
||||||
|
description:
|
||||||
|
'Retrieve details of a specific sent email by its ID, including status, recipients, and content.',
|
||||||
|
inputSchema: jsonSchema<IdInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'The email ID to retrieve.' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: IdInput) {
|
||||||
|
return apiRequest<Record<string, unknown>>('GET', `/emails/${input.id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface UpdateEmailInput {
|
||||||
|
id: string;
|
||||||
|
scheduled_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateEmail = tool({
|
||||||
|
description:
|
||||||
|
'Update a scheduled email by changing its scheduled delivery time. Only works on emails not yet sent.',
|
||||||
|
inputSchema: jsonSchema<UpdateEmailInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'The email ID to update.' },
|
||||||
|
scheduled_at: { type: 'string', description: 'New scheduled time in ISO 8601 format.' },
|
||||||
|
},
|
||||||
|
required: ['id', 'scheduled_at'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: UpdateEmailInput) {
|
||||||
|
const { id, ...body } = input;
|
||||||
|
return apiRequest<{ object: string; id: string }>('PATCH', `/emails/${id}`, body);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cancelEmail = tool({
|
||||||
|
description:
|
||||||
|
'Cancel a scheduled email that has not been sent yet. Returns the cancelled email ID.',
|
||||||
|
inputSchema: jsonSchema<IdInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'The email ID to cancel.' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: IdInput) {
|
||||||
|
return apiRequest<{ object: string; id: string }>('POST', `/emails/${input.id}/cancel`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listEmails = tool({
|
||||||
|
description:
|
||||||
|
'List sent emails with optional cursor-based pagination. Returns email summaries with status.',
|
||||||
|
inputSchema: jsonSchema<PaginationInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: { type: 'number', description: 'Max results to return (1-100, default 20).' },
|
||||||
|
after: { type: 'string', description: 'Cursor ID for forward pagination.' },
|
||||||
|
before: { type: 'string', description: 'Cursor ID for backward pagination.' },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: PaginationInput) {
|
||||||
|
const qs = buildQueryString({ ...input });
|
||||||
|
return apiRequest<{ object: string; has_more: boolean; data: unknown[] }>(
|
||||||
|
'GET',
|
||||||
|
`/emails${qs}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Domains ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CreateDomainInput {
|
||||||
|
name: string;
|
||||||
|
region?: string;
|
||||||
|
custom_return_path?: string;
|
||||||
|
open_tracking?: boolean;
|
||||||
|
click_tracking?: boolean;
|
||||||
|
tls?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDomain = tool({
|
||||||
|
description:
|
||||||
|
'Add a new sending domain to your Resend account. Returns the domain ID and DNS records to configure.',
|
||||||
|
inputSchema: jsonSchema<CreateDomainInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', description: 'The domain name to add (e.g. "example.com").' },
|
||||||
|
region: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Region: us-east-1, eu-west-1, sa-east-1, or ap-northeast-1.',
|
||||||
|
},
|
||||||
|
custom_return_path: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Subdomain for Return-Path (default: "send").',
|
||||||
|
},
|
||||||
|
open_tracking: { type: 'boolean', description: 'Enable open rate tracking.' },
|
||||||
|
click_tracking: { type: 'boolean', description: 'Enable click tracking in HTML emails.' },
|
||||||
|
tls: { type: 'string', description: 'TLS mode: "opportunistic" or "enforced".' },
|
||||||
|
},
|
||||||
|
required: ['name'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: CreateDomainInput) {
|
||||||
|
return apiRequest<Record<string, unknown>>('POST', '/domains', input);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface DomainIdInput {
|
||||||
|
domain_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDomain = tool({
|
||||||
|
description:
|
||||||
|
'Retrieve details and DNS records for a specific domain including verification status.',
|
||||||
|
inputSchema: jsonSchema<DomainIdInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
domain_id: { type: 'string', description: 'The domain ID to retrieve.' },
|
||||||
|
},
|
||||||
|
required: ['domain_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: DomainIdInput) {
|
||||||
|
return apiRequest<Record<string, unknown>>('GET', `/domains/${input.domain_id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface UpdateDomainInput {
|
||||||
|
domain_id: string;
|
||||||
|
click_tracking?: boolean;
|
||||||
|
open_tracking?: boolean;
|
||||||
|
tls?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateDomain = tool({
|
||||||
|
description: 'Update tracking, TLS, and capability settings for an existing domain.',
|
||||||
|
inputSchema: jsonSchema<UpdateDomainInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
domain_id: { type: 'string', description: 'The domain ID to update.' },
|
||||||
|
click_tracking: { type: 'boolean', description: 'Enable or disable click tracking.' },
|
||||||
|
open_tracking: { type: 'boolean', description: 'Enable or disable open tracking.' },
|
||||||
|
tls: { type: 'string', description: 'TLS mode: "opportunistic" or "enforced".' },
|
||||||
|
},
|
||||||
|
required: ['domain_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: UpdateDomainInput) {
|
||||||
|
const { domain_id, ...body } = input;
|
||||||
|
return apiRequest<{ object: string; id: string }>('PATCH', `/domains/${domain_id}`, body);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteDomain = tool({
|
||||||
|
description: 'Delete a sending domain from your Resend account permanently.',
|
||||||
|
inputSchema: jsonSchema<DomainIdInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
domain_id: { type: 'string', description: 'The domain ID to delete.' },
|
||||||
|
},
|
||||||
|
required: ['domain_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: DomainIdInput) {
|
||||||
|
return apiRequest<{ object: string; id: string; deleted: boolean }>(
|
||||||
|
'DELETE',
|
||||||
|
`/domains/${input.domain_id}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listDomains = tool({
|
||||||
|
description: 'List all sending domains in your Resend account with their verification status.',
|
||||||
|
inputSchema: jsonSchema<PaginationInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: { type: 'number', description: 'Max results to return (1-100, default 20).' },
|
||||||
|
after: { type: 'string', description: 'Cursor ID for forward pagination.' },
|
||||||
|
before: { type: 'string', description: 'Cursor ID for backward pagination.' },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: PaginationInput) {
|
||||||
|
const qs = buildQueryString({ ...input });
|
||||||
|
return apiRequest<{ object: string; has_more: boolean; data: unknown[] }>(
|
||||||
|
'GET',
|
||||||
|
`/domains${qs}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const verifyDomain = tool({
|
||||||
|
description:
|
||||||
|
'Trigger DNS verification for a sending domain. Check domain status after DNS records are configured.',
|
||||||
|
inputSchema: jsonSchema<DomainIdInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
domain_id: { type: 'string', description: 'The domain ID to verify.' },
|
||||||
|
},
|
||||||
|
required: ['domain_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: DomainIdInput) {
|
||||||
|
return apiRequest<{ object: string; id: string }>('POST', `/domains/${input.domain_id}/verify`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── API Keys ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CreateApiKeyInput {
|
||||||
|
name: string;
|
||||||
|
permission?: string;
|
||||||
|
domain_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createApiKey = tool({
|
||||||
|
description: 'Create a new Resend API key with specified name and permission level.',
|
||||||
|
inputSchema: jsonSchema<CreateApiKeyInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', description: 'Name for the API key (max 50 characters).' },
|
||||||
|
permission: { type: 'string', description: 'Permission: "full_access" or "sending_access".' },
|
||||||
|
domain_id: { type: 'string', description: 'Restrict to a domain (only for sending_access).' },
|
||||||
|
},
|
||||||
|
required: ['name'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: CreateApiKeyInput) {
|
||||||
|
return apiRequest<{ id: string; token: string }>('POST', '/api-keys', input);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listApiKeys = tool({
|
||||||
|
description: 'List all API keys in your Resend account with their names and creation dates.',
|
||||||
|
inputSchema: jsonSchema<PaginationInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: { type: 'number', description: 'Max results to return (1-100).' },
|
||||||
|
after: { type: 'string', description: 'Cursor ID for forward pagination.' },
|
||||||
|
before: { type: 'string', description: 'Cursor ID for backward pagination.' },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: PaginationInput) {
|
||||||
|
const qs = buildQueryString({ ...input });
|
||||||
|
return apiRequest<{ object: string; has_more: boolean; data: unknown[] }>(
|
||||||
|
'GET',
|
||||||
|
`/api-keys${qs}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ApiKeyIdInput {
|
||||||
|
api_key_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteApiKey = tool({
|
||||||
|
description: 'Delete an API key from your Resend account permanently.',
|
||||||
|
inputSchema: jsonSchema<ApiKeyIdInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
api_key_id: { type: 'string', description: 'The API key ID to delete.' },
|
||||||
|
},
|
||||||
|
required: ['api_key_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: ApiKeyIdInput) {
|
||||||
|
return apiRequest<Record<string, unknown>>('DELETE', `/api-keys/${input.api_key_id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Contacts ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CreateContactInput {
|
||||||
|
email: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
unsubscribed?: boolean;
|
||||||
|
properties?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createContact = tool({
|
||||||
|
description:
|
||||||
|
'Create a new contact with an email address and optional name, properties, and subscription settings.',
|
||||||
|
inputSchema: jsonSchema<CreateContactInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
email: { type: 'string', description: 'Contact email address.' },
|
||||||
|
first_name: { type: 'string', description: 'Contact first name.' },
|
||||||
|
last_name: { type: 'string', description: 'Contact last name.' },
|
||||||
|
unsubscribed: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'If true, contact is unsubscribed from all broadcasts.',
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: { type: 'string' },
|
||||||
|
description: 'Custom key-value properties for the contact.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['email'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: CreateContactInput) {
|
||||||
|
return apiRequest<{ object: string; id: string }>('POST', '/contacts', input);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getContact = tool({
|
||||||
|
description:
|
||||||
|
'Retrieve a contact by their ID or email address, including name, properties, and subscription status.',
|
||||||
|
inputSchema: jsonSchema<IdInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Contact ID or email address.' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: IdInput) {
|
||||||
|
return apiRequest<Record<string, unknown>>('GET', `/contacts/${encodeURIComponent(input.id)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface UpdateContactInput {
|
||||||
|
id: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
unsubscribed?: boolean;
|
||||||
|
properties?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateContact = tool({
|
||||||
|
description: "Update a contact's name, subscription status, or custom properties.",
|
||||||
|
inputSchema: jsonSchema<UpdateContactInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Contact ID or email address.' },
|
||||||
|
first_name: { type: 'string', description: 'Updated first name.' },
|
||||||
|
last_name: { type: 'string', description: 'Updated last name.' },
|
||||||
|
unsubscribed: { type: 'boolean', description: 'Updated subscription status.' },
|
||||||
|
properties: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: { type: 'string' },
|
||||||
|
description: 'Updated custom properties.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: UpdateContactInput) {
|
||||||
|
const { id, ...body } = input;
|
||||||
|
return apiRequest<{ object: string; id: string }>(
|
||||||
|
'PATCH',
|
||||||
|
`/contacts/${encodeURIComponent(id)}`,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteContact = tool({
|
||||||
|
description: 'Delete a contact by their ID or email address permanently.',
|
||||||
|
inputSchema: jsonSchema<IdInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Contact ID or email address to delete.' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: IdInput) {
|
||||||
|
return apiRequest<{ object: string; contact: string; deleted: boolean }>(
|
||||||
|
'DELETE',
|
||||||
|
`/contacts/${encodeURIComponent(input.id)}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ListContactsInput extends PaginationInput {
|
||||||
|
segment_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listContacts = tool({
|
||||||
|
description: 'List contacts with optional segment filtering and cursor-based pagination.',
|
||||||
|
inputSchema: jsonSchema<ListContactsInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
segment_id: { type: 'string', description: 'Filter contacts by segment ID.' },
|
||||||
|
limit: { type: 'number', description: 'Max results to return (1-100, default 20).' },
|
||||||
|
after: { type: 'string', description: 'Cursor ID for forward pagination.' },
|
||||||
|
before: { type: 'string', description: 'Cursor ID for backward pagination.' },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: ListContactsInput) {
|
||||||
|
const qs = buildQueryString({ ...input });
|
||||||
|
return apiRequest<{ object: string; has_more: boolean; data: unknown[] }>(
|
||||||
|
'GET',
|
||||||
|
`/contacts${qs}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Broadcasts ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CreateBroadcastInput {
|
||||||
|
segment_id: string;
|
||||||
|
from: string;
|
||||||
|
subject: string;
|
||||||
|
reply_to?: string | string[];
|
||||||
|
html?: string;
|
||||||
|
text?: string;
|
||||||
|
name?: string;
|
||||||
|
topic_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createBroadcast = tool({
|
||||||
|
description:
|
||||||
|
'Create a new broadcast email draft to send to a segment. Use sendBroadcast to dispatch it.',
|
||||||
|
inputSchema: jsonSchema<CreateBroadcastInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
segment_id: { type: 'string', description: 'Segment ID to send the broadcast to.' },
|
||||||
|
from: { type: 'string', description: 'Sender address. Supports "Name <email>" format.' },
|
||||||
|
subject: { type: 'string', description: 'Broadcast email subject line.' },
|
||||||
|
reply_to: {
|
||||||
|
oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
|
||||||
|
description: 'Reply-to address(es).',
|
||||||
|
},
|
||||||
|
html: { type: 'string', description: 'HTML content. Supports Contact Property templating.' },
|
||||||
|
text: { type: 'string', description: 'Plain text content.' },
|
||||||
|
name: { type: 'string', description: 'Friendly name for internal reference.' },
|
||||||
|
topic_id: { type: 'string', description: 'Topic ID for subscription management.' },
|
||||||
|
},
|
||||||
|
required: ['segment_id', 'from', 'subject'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: CreateBroadcastInput) {
|
||||||
|
return apiRequest<{ id: string }>('POST', '/broadcasts', input);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listBroadcasts = tool({
|
||||||
|
description: 'List all broadcasts with their status, audience, and scheduling details.',
|
||||||
|
inputSchema: jsonSchema<PaginationInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: { type: 'number', description: 'Max results to return (1-100, default 20).' },
|
||||||
|
after: { type: 'string', description: 'Cursor ID for forward pagination.' },
|
||||||
|
before: { type: 'string', description: 'Cursor ID for backward pagination.' },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: PaginationInput) {
|
||||||
|
const qs = buildQueryString({ ...input });
|
||||||
|
return apiRequest<{ object: string; has_more: boolean; data: unknown[] }>(
|
||||||
|
'GET',
|
||||||
|
`/broadcasts${qs}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface SendBroadcastInput {
|
||||||
|
broadcast_id: string;
|
||||||
|
scheduled_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendBroadcast = tool({
|
||||||
|
description:
|
||||||
|
'Send or schedule a previously created broadcast. Optionally provide a scheduled_at time.',
|
||||||
|
inputSchema: jsonSchema<SendBroadcastInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
broadcast_id: { type: 'string', description: 'The broadcast ID to send.' },
|
||||||
|
scheduled_at: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'ISO 8601 datetime or natural language to schedule.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['broadcast_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: SendBroadcastInput) {
|
||||||
|
const { broadcast_id, ...body } = input;
|
||||||
|
const hasBody = Object.keys(body).length > 0;
|
||||||
|
return apiRequest<{ id: string }>(
|
||||||
|
'POST',
|
||||||
|
`/broadcasts/${broadcast_id}/send`,
|
||||||
|
hasBody ? body : undefined
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface BroadcastIdInput {
|
||||||
|
broadcast_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteBroadcast = tool({
|
||||||
|
description:
|
||||||
|
'Delete a draft broadcast that has not been sent. Scheduled broadcasts are automatically cancelled.',
|
||||||
|
inputSchema: jsonSchema<BroadcastIdInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
broadcast_id: { type: 'string', description: 'The broadcast ID to delete.' },
|
||||||
|
},
|
||||||
|
required: ['broadcast_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: BroadcastIdInput) {
|
||||||
|
return apiRequest<{ object: string; id: string; deleted: boolean }>(
|
||||||
|
'DELETE',
|
||||||
|
`/broadcasts/${input.broadcast_id}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Audiences ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface NameInput {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAudience = tool({
|
||||||
|
description: 'Create a new audience for organizing contacts into groups.',
|
||||||
|
inputSchema: jsonSchema<NameInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', description: 'Name for the audience.' },
|
||||||
|
},
|
||||||
|
required: ['name'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: NameInput) {
|
||||||
|
return apiRequest<{ object: string; id: string; name: string }>('POST', '/audiences', input);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listAudiences = tool({
|
||||||
|
description: 'List all audiences in your Resend account with their names and creation dates.',
|
||||||
|
inputSchema: jsonSchema<PaginationInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: { type: 'number', description: 'Max results to return (1-100, default 20).' },
|
||||||
|
after: { type: 'string', description: 'Cursor ID for forward pagination.' },
|
||||||
|
before: { type: 'string', description: 'Cursor ID for backward pagination.' },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: PaginationInput) {
|
||||||
|
const qs = buildQueryString({ ...input });
|
||||||
|
return apiRequest<{ object: string; has_more: boolean; data: unknown[] }>(
|
||||||
|
'GET',
|
||||||
|
`/audiences${qs}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface AudienceIdInput {
|
||||||
|
audience_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAudience = tool({
|
||||||
|
description: 'Retrieve details of a specific audience by its ID.',
|
||||||
|
inputSchema: jsonSchema<AudienceIdInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
audience_id: { type: 'string', description: 'The audience ID to retrieve.' },
|
||||||
|
},
|
||||||
|
required: ['audience_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: AudienceIdInput) {
|
||||||
|
return apiRequest<Record<string, unknown>>('GET', `/audiences/${input.audience_id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteAudience = tool({
|
||||||
|
description: 'Delete an audience from your Resend account permanently.',
|
||||||
|
inputSchema: jsonSchema<AudienceIdInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
audience_id: { type: 'string', description: 'The audience ID to delete.' },
|
||||||
|
},
|
||||||
|
required: ['audience_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: AudienceIdInput) {
|
||||||
|
return apiRequest<{ object: string; id: string; deleted: boolean }>(
|
||||||
|
'DELETE',
|
||||||
|
`/audiences/${input.audience_id}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Default Export ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Emails
|
||||||
|
sendEmail,
|
||||||
|
sendBatchEmails,
|
||||||
|
getEmail,
|
||||||
|
updateEmail,
|
||||||
|
cancelEmail,
|
||||||
|
listEmails,
|
||||||
|
// Domains
|
||||||
|
createDomain,
|
||||||
|
getDomain,
|
||||||
|
updateDomain,
|
||||||
|
deleteDomain,
|
||||||
|
listDomains,
|
||||||
|
verifyDomain,
|
||||||
|
// API Keys
|
||||||
|
createApiKey,
|
||||||
|
listApiKeys,
|
||||||
|
deleteApiKey,
|
||||||
|
// Contacts
|
||||||
|
createContact,
|
||||||
|
getContact,
|
||||||
|
updateContact,
|
||||||
|
deleteContact,
|
||||||
|
listContacts,
|
||||||
|
// Broadcasts
|
||||||
|
createBroadcast,
|
||||||
|
listBroadcasts,
|
||||||
|
sendBroadcast,
|
||||||
|
deleteBroadcast,
|
||||||
|
// Audiences
|
||||||
|
createAudience,
|
||||||
|
listAudiences,
|
||||||
|
getAudience,
|
||||||
|
deleteAudience,
|
||||||
|
};
|
||||||
11
packages/tools/official/resend/tsconfig.json
Normal file
11
packages/tools/official/resend/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": "@tpmjs/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"incremental": false,
|
||||||
|
"composite": false
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
10
packages/tools/official/resend/tsup.config.ts
Normal file
10
packages/tools/official/resend/tsup.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
format: ['esm'],
|
||||||
|
dts: true,
|
||||||
|
clean: true,
|
||||||
|
treeshake: true,
|
||||||
|
splitting: false,
|
||||||
|
});
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import {
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||||
AbsoluteFill,
|
import { colors, springConfigs, typography } from '../../design-tokens';
|
||||||
interpolate,
|
|
||||||
spring,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
} from 'remotion';
|
|
||||||
import { colors, typography, springConfigs } from '../../design-tokens';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature 3: Collections (0:24 - 0:32)
|
* Feature 3: Collections (0:24 - 0:32)
|
||||||
|
|
@ -92,7 +86,7 @@ const CollectionCard = ({
|
||||||
>
|
>
|
||||||
{Array.from({ length: Math.min(toolCount, 5) }).map((_, i) => (
|
{Array.from({ length: Math.min(toolCount, 5) }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={`dot-${String(i)}`}
|
||||||
style={{
|
style={{
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
|
|
@ -234,8 +228,7 @@ export const CollectionsFeatureScene = () => {
|
||||||
>
|
>
|
||||||
Curate tool sets
|
Curate tool sets
|
||||||
<br />
|
<br />
|
||||||
for specific{' '}
|
for specific <span style={{ color: colors.copper.default }}>use cases</span>
|
||||||
<span style={{ color: colors.copper.default }}>use cases</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import {
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||||
AbsoluteFill,
|
import { colors, springConfigs, typography } from '../../design-tokens';
|
||||||
interpolate,
|
|
||||||
spring,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
} from 'remotion';
|
|
||||||
import { colors, typography, springConfigs } from '../../design-tokens';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature 4: Custom Agents (0:32 - 0:40)
|
* Feature 4: Custom Agents (0:32 - 0:40)
|
||||||
|
|
@ -108,8 +102,7 @@ const AgentCard = ({
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 10px',
|
padding: '4px 10px',
|
||||||
backgroundColor:
|
backgroundColor: status === 'public' ? colors.status.successMuted : colors.bg.surface2,
|
||||||
status === 'public' ? colors.status.successMuted : colors.bg.surface2,
|
|
||||||
border: `1px solid ${status === 'public' ? colors.status.success : colors.border.default}`,
|
border: `1px solid ${status === 'public' ? colors.status.success : colors.border.default}`,
|
||||||
fontSize: typography.fontSize.xs,
|
fontSize: typography.fontSize.xs,
|
||||||
color: status === 'public' ? colors.status.success : colors.text.muted,
|
color: status === 'public' ? colors.status.success : colors.text.muted,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import {
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||||
AbsoluteFill,
|
import { colors, springConfigs, typography } from '../../design-tokens';
|
||||||
interpolate,
|
|
||||||
spring,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
} from 'remotion';
|
|
||||||
import { colors, typography, springConfigs } from '../../design-tokens';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature 9: Developer SDK (1:13 - 1:22)
|
* Feature 9: Developer SDK (1:13 - 1:22)
|
||||||
|
|
@ -201,11 +195,7 @@ export const DeveloperSDKScene = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{codeLines.map((line, i) => (
|
{codeLines.map((line, i) => (
|
||||||
<CodeLine
|
<CodeLine key={line.text} {...line} delay={fps * 0.5 + i * 5} />
|
||||||
key={i}
|
|
||||||
{...line}
|
|
||||||
delay={fps * 0.5 + i * 5}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import {
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||||
AbsoluteFill,
|
import { colors, springConfigs, typography } from '../../design-tokens';
|
||||||
interpolate,
|
|
||||||
spring,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
} from 'remotion';
|
|
||||||
import { colors, typography, springConfigs } from '../../design-tokens';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature 8: Living Skills (1:05 - 1:13)
|
* Feature 8: Living Skills (1:05 - 1:13)
|
||||||
|
|
@ -189,8 +183,7 @@ export const LivingSkillsScene = () => {
|
||||||
opacity: headerProgress,
|
opacity: headerProgress,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Documentation that{' '}
|
Documentation that <span style={{ color: colors.copper.default }}>evolves</span>
|
||||||
<span style={{ color: colors.copper.default }}>evolves</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import {
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||||
AbsoluteFill,
|
import { colors, springConfigs, typography } from '../../design-tokens';
|
||||||
interpolate,
|
|
||||||
spring,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
} from 'remotion';
|
|
||||||
import { colors, typography, springConfigs } from '../../design-tokens';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature 5: MCP Protocol (0:40 - 0:49)
|
* Feature 5: MCP Protocol (0:40 - 0:49)
|
||||||
|
|
@ -253,11 +247,7 @@ export const MCPProtocolScene = () => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{clients.map((client, i) => (
|
{clients.map((client, i) => (
|
||||||
<ClientLogo
|
<ClientLogo key={client.name} {...client} delay={fps * 1 + i * 8} />
|
||||||
key={client.name}
|
|
||||||
{...client}
|
|
||||||
delay={fps * 1 + i * 8}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -281,7 +271,7 @@ export const MCPProtocolScene = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
key={i}
|
key={`path-${String(i)}`}
|
||||||
d={path}
|
d={path}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={colors.copper.default}
|
stroke={colors.copper.default}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import {
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||||
AbsoluteFill,
|
import { colors, springConfigs, typography } from '../../design-tokens';
|
||||||
interpolate,
|
|
||||||
spring,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
} from 'remotion';
|
|
||||||
import { colors, typography, springConfigs } from '../../design-tokens';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature 2: Omega Agent (0:15 - 0:24)
|
* Feature 2: Omega Agent (0:15 - 0:24)
|
||||||
|
|
@ -13,11 +7,11 @@ import { colors, typography, springConfigs } from '../../design-tokens';
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const Message = ({
|
const Message = ({
|
||||||
role,
|
sender,
|
||||||
content,
|
content,
|
||||||
delay,
|
delay,
|
||||||
}: {
|
}: {
|
||||||
role: 'user' | 'assistant';
|
sender: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
delay: number;
|
delay: number;
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -30,7 +24,7 @@ const Message = ({
|
||||||
config: springConfigs.snappy,
|
config: springConfigs.snappy,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isUser = role === 'user';
|
const isUser = sender === 'user';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -195,7 +189,7 @@ export const OmegaAgentScene = () => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Message
|
<Message
|
||||||
role="user"
|
sender="user"
|
||||||
content="Scrape competitor pricing pages and create a comparison chart"
|
content="Scrape competitor pricing pages and create a comparison chart"
|
||||||
delay={fps * 0.5}
|
delay={fps * 0.5}
|
||||||
/>
|
/>
|
||||||
|
|
@ -230,7 +224,7 @@ export const OmegaAgentScene = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Message
|
<Message
|
||||||
role="assistant"
|
sender="assistant"
|
||||||
content="I've scraped 5 competitor pages and generated a comparison chart. Found 23% average price difference..."
|
content="I've scraped 5 competitor pages and generated a comparison chart. Found 23% average price difference..."
|
||||||
delay={fps * 3.5}
|
delay={fps * 3.5}
|
||||||
/>
|
/>
|
||||||
|
|
@ -256,8 +250,7 @@ export const OmegaAgentScene = () => {
|
||||||
color: colors.text.secondary,
|
color: colors.text.secondary,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
No configuration needed.{' '}
|
No configuration needed. <span style={{ color: colors.copper.default }}>Just ask.</span>
|
||||||
<span style={{ color: colors.copper.default }}>Just ask.</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import {
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||||
AbsoluteFill,
|
import { colors, springConfigs, typography } from '../../design-tokens';
|
||||||
interpolate,
|
|
||||||
spring,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
} from 'remotion';
|
|
||||||
import { colors, typography, springConfigs } from '../../design-tokens';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature 6: Secure Execution (0:49 - 0:57)
|
* Feature 6: Secure Execution (0:49 - 0:57)
|
||||||
|
|
@ -222,12 +216,7 @@ export const SecureExecutionScene = () => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{layers.map((layer, i) => (
|
{layers.map((layer, i) => (
|
||||||
<SecurityLayer
|
<SecurityLayer key={layer.label} {...layer} index={i} delay={fps * 1 + i * 10} />
|
||||||
key={layer.label}
|
|
||||||
{...layer}
|
|
||||||
index={i}
|
|
||||||
delay={fps * 1 + i * 10}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import {
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||||
AbsoluteFill,
|
import { colors, springConfigs, typography } from '../../design-tokens';
|
||||||
interpolate,
|
|
||||||
spring,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
} from 'remotion';
|
|
||||||
import { colors, typography, springConfigs } from '../../design-tokens';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature 7: Test Scenarios (0:57 - 1:05)
|
* Feature 7: Test Scenarios (0:57 - 1:05)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import {
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||||
AbsoluteFill,
|
import { colors, springConfigs, typography } from '../../design-tokens';
|
||||||
interpolate,
|
|
||||||
spring,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
} from 'remotion';
|
|
||||||
import { colors, typography, springConfigs } from '../../design-tokens';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature 1: Tool Registry (0:06 - 0:15)
|
* Feature 1: Tool Registry (0:06 - 0:15)
|
||||||
|
|
@ -250,11 +244,7 @@ export const ToolRegistryScene = () => {
|
||||||
{/* Right - Tool list */}
|
{/* Right - Tool list */}
|
||||||
<div style={{ flex: 1, maxWidth: 500 }}>
|
<div style={{ flex: 1, maxWidth: 500 }}>
|
||||||
{tools.map((tool, i) => (
|
{tools.map((tool, i) => (
|
||||||
<ToolCard
|
<ToolCard key={tool.name} {...tool} delay={fps * 1 + i * 10} />
|
||||||
key={tool.name}
|
|
||||||
{...tool}
|
|
||||||
delay={fps * 1 + i * 10}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
|
|
@ -2896,6 +2896,22 @@ importers:
|
||||||
specifier: ^5.9.3
|
specifier: ^5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/tools/official/resend:
|
||||||
|
dependencies:
|
||||||
|
ai:
|
||||||
|
specifier: 6.0.49
|
||||||
|
version: 6.0.49(zod@4.3.5)
|
||||||
|
devDependencies:
|
||||||
|
'@tpmjs/tsconfig':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../config/tsconfig
|
||||||
|
tsup:
|
||||||
|
specifier: ^8.5.1
|
||||||
|
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.9.3
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
packages/tools/official/response-template-suggest:
|
packages/tools/official/response-template-suggest:
|
||||||
dependencies:
|
dependencies:
|
||||||
ai:
|
ai:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue