@clawhub-lucaperret-7d7e6c6f88
Prepare and submit an MCP server to Anthropic's Connectors Directory. Use this skill whenever the user wants to list their MCP server on Claude's connector m...
---
name: anthropic-connector-submit
description: "Prepare and submit an MCP server to Anthropic's Connectors Directory. Use this skill whenever the user wants to list their MCP server on Claude's connector marketplace, submit to Anthropic's directory, get their MCP server featured in Claude Desktop or claude.ai, or prepare a connector submission. Also triggers on: 'submit to Anthropic', 'Claude connector directory', 'list on Claude marketplace', 'Anthropic MCP submission', 'get listed in Claude connectors', 'MCP directory submission form'. Even if the user just says 'how do I get my MCP server in the Claude directory' or 'I want Claude users to find my server', use this skill."
license: MIT
metadata:
author: lucaperret
version: "1.0.0"
openclaw:
emoji: "\U0001F4CB"
homepage: https://github.com/lucaperret/agent-skills
---
# Submit to Anthropic Connectors Directory
Get your MCP server listed alongside Gmail, Notion, and Slack in Claude's official connector marketplace. This covers the complete submission process — from technical requirements to filling out the 6-page form.
## Prerequisites checklist
Before submitting, your server must meet ALL of these. Missing any one will delay or reject your submission.
- [ ] MCP server is live, deployed, and accessible via HTTPS
- [ ] OAuth 2.0 implemented (if auth required)
- [ ] All tools have safety annotations (`readOnlyHint`, `destructiveHint`)
- [ ] All tools have human-readable titles
- [ ] Privacy policy published at a public URL
- [ ] Terms of service published at a public URL
- [ ] Support channel (GitHub issues or email)
- [ ] At least 3 example prompts prepared
- [ ] Test account with sample data ready
- [ ] Server tested end-to-end on Claude Desktop or claude.ai
## The submission form
Submit at: https://docs.google.com/forms/d/e/1FAIpQLSeafJF2NDI7oYx1r8o0ycivCSVLNq92Mpc1FPxMKSw1CzDkqA/viewform
The form has 6 pages. Here's what to put in each field.
### Page 1: Submission Details
**Company Information:**
- **Company/Organization Name** — Your name or company
- **Company/Organization URL** — Your website
- **Primary Contact Name** — Your full name
- **Primary Contact Email** — Your email (reviewers will contact you here)
- **Primary Contact Role** — e.g., "Creator & Maintainer"
- **Anthropic Point of Contact** — Leave blank unless you know someone
**Server Details:**
- **MCP Server Name** — Short name, no "MCP" or "Server" in it (e.g., "Tidal", not "Tidal MCP Server")
- **MCP Server URL** — Select "Universal URL", then enter your endpoint (e.g., `https://example.com/api/mcp`)
- **Tagline** — Max 55 characters. Format: "Verb your thing with Claude" (e.g., "Search, play, and manage your Tidal music library")
**MCP Server Description** (50-100 words, shown in-app):
Template: "[Action] your [service] directly from Claude. [Key capabilities in 2-3 sentences]. [Number] tools covering [categories]. OAuth authentication with your own [service] account."
**Use Cases + Examples** (minimum 3):
Each example should show a realistic user prompt and explain what tools fire. Template:
```
**[Use Case Category]**
"[Realistic user prompt]"
[Which tools are called and what happens, in 1-2 sentences]
```
Good examples show multi-step orchestration — not just single tool calls.
**Connection requirements:**
State what users need (e.g., "Requires a [Service] subscription. Available worldwide.")
**Read/Write Capabilities:** Select "Read + Write" if you have any write tools.
**Is this an MCP App:** Select "No" unless you have interactive UI elements.
**Third-party Connections:** Check what applies:
- "Third-party data retrieval" — if you fetch from an external API
- "Third-party data modification" — if you write to an external API
**Data Handling:** Check all that apply (typically all 4):
- Server only accesses data explicitly requested by user
- No data is stored beyond session requirements
- Data transmission is encrypted (HTTPS/TLS)
- GDPR compliant
**Personal health data:** "No" (unless your service handles health data)
**Categories:** Pick the closest (e.g., "Media & Entertainment", "Business & Productivity")
**Sponsored content:** "No, there is no sponsored content or advertisements"
**Authentication:**
- **Authentication Type:** "OAuth 2.0"
- **Auth Client:** "Dynamic OAuth Client (e.g., DCR, CIMD)"
- **Static Client ID/Secret:** Leave blank for dynamic registration
- **Transport Support:** Check "Streamable HTTP"
**Documentation & Support:**
- **Documentation Link** — Your GitHub repo or docs site
- **Privacy Policy** — URL to your privacy policy page
- **Data Processing Agreement URL** — Leave blank unless you have one
- **Support Channel** — GitHub issues URL or support email
### Page 2: Testing
**Testing Account Credentials:**
If your server requires auth, provide credentials. Recommended: "Test account credentials will be shared via 1Password secure link. Contact [email] to receive the link."
**Test Account Setup Instructions:**
Write clear steps:
```
1. Click "Connect" on the [Name] connector in Claude
2. Log in with the test credentials provided
3. Try: "[example prompt]" to verify
The test account has [what data is pre-loaded]
```
**Test Data Availability:** Check both:
- Test account includes sample data
- All tools can be tested with provided data
**List of tools:**
Comma-separated, format: `tool_name (Human-Readable Name)`
**Tool Titles & Annotations:** Check both:
- I've specified user-friendly titles for all tools
- I've specified accurate tool annotations for all tools
**List of resources:** Leave blank (unless you expose MCP resources)
**List of prompts:** Leave blank (unless you expose MCP prompts, which are optional)
### Page 3: Launch Readiness & Media
**Timeline - Server GA Date:** Today's date (your server should already be live)
**Testing complete:** Check where you've tested:
- Claude.ai (web)
- Claude Desktop
**Server Logo:** Provide your SVG logo URL or upload it. Square, 1:1 aspect ratio.
**Server Logo URL:** Verify that `https://www.google.com/s2/favicons?domain=YOUR_DOMAIN&sz=64` returns your icon. Check the box to confirm.
**Promotional Images:** Upload 3-5 screenshots of your MCP server in use on Claude. Show:
1. A search/discovery interaction
2. A create/write operation
3. A multi-step orchestration
Tips for screenshots:
- Use wide window (1000px+)
- Start conversation with "Respond in English. Format with clear headings and tables."
- Show the tool calls and results clearly
**Link to Promotional Materials:** Optional. Your website URL.
### Page 4: Skills & Plugins (Optional)
If you have a SKILL.md file for Claude Code:
- **Skill Name** — Your skill name
- **Skill Description** — Short description
- **GitHub URL of Skill** — Public repo URL
- **Extra Information** — Mention other marketplaces (ClawHub, skills.sh)
### Page 5: Submission Requirements Checklist
**Policy Compliance** — Check all 5 (all are required):
- I have reviewed and agree to the Software Directory Policy
- My server does NOT enable cross-service automation
- My server does NOT transfer money
- My MCP server is live and ready for production traffic
- I work for the company that owns/controls the API endpoints
Note: The last checkbox is about YOUR MCP server endpoint, not the upstream API.
**Technical Requirements** — Check all 6:
- OAuth 2.0 fully implemented
- All tools include safety annotations
- Server accessible via HTTPS
- CORS configured
- Claude IPs allowlisted (check if behind firewall)
- Tested on latest Claude build
**Documentation Requirements** — Check all 4:
- Documentation published and accessible
- Includes setup instructions and tool descriptions
- Privacy policy published
- Terms of service published
**Testing Requirements** — Check all 3:
- Test account with sample data ready
- Test credentials valid for 30+ days
- All tools functional and tested
**Additional Information:** Optional. Good place to mention:
- Open source status and license
- Where else the server is available (Smithery, ClawHub)
- Any unique value proposition
## After submission
- Anthropic reviews submissions but cannot guarantee inclusion or timeline
- For updates to an existing listing, email [email protected]
- Check back in 2-3 weeks if no response
## Privacy policy template
Your privacy policy should cover:
- What data is collected (OAuth tokens, user queries)
- Where data is stored (Redis/database, TTL/retention)
- What third-party services are used (upstream API, hosting)
- How to delete data (revoke access, delete session files)
- Contact information
## Terms of service template
Your ToS should cover:
- Service description
- Requirements (subscription needed, acceptable use)
- Disclaimer of warranties ("as is")
- Limitation of liability
- Third-party services and their terms
- Open source license
## Also publish on Smithery
After submitting to Anthropic, also publish on Smithery for immediate distribution:
1. Go to https://smithery.ai/new
2. Enter your MCP server URL
3. Smithery scans tools automatically
4. Configure display name, description, homepage, icon
5. Uncheck "Unlisted" to appear in search
Smithery gives you instant visibility while waiting for Anthropic's review.
Add OAuth 2.0 PKCE authentication to a remote MCP server. Use this skill whenever the user wants to add authentication to an MCP server, protect MCP tools wi...
---
name: mcp-oauth
description: "Add OAuth 2.0 PKCE authentication to a remote MCP server. Use this skill whenever the user wants to add authentication to an MCP server, protect MCP tools with OAuth, implement login flow for an MCP connector, add user auth to an MCP endpoint, or set up token-based access for MCP. Also triggers on: 'MCP OAuth', 'MCP authentication', 'withMcpAuth', 'MCP login flow', 'protect MCP endpoint', 'MCP token auth', 'dynamic client registration MCP', 'Claude connector OAuth'. Even if the user just says 'add auth to my MCP server' or 'my MCP server needs login', use this skill."
license: MIT
metadata:
author: lucaperret
version: "1.0.0"
openclaw:
emoji: "\U0001F512"
homepage: https://github.com/lucaperret/agent-skills
---
# OAuth 2.0 PKCE for MCP Servers
Add production-ready OAuth authentication to a remote MCP server. This implements the full MCP authorization spec — discovery, dynamic client registration, PKCE authorization, token exchange, and refresh.
## When you need this
Your MCP server accesses user-specific data (their account, their files, their playlists). Without auth, anyone with your server URL could access anyone's data. OAuth lets each user authenticate with their own credentials and get their own token.
## Architecture overview
Your MCP server plays two roles:
1. **OAuth server** for MCP clients (Claude, Smithery) — issues your own tokens
2. **OAuth client** to the upstream service (Tidal, GitHub, Slack, etc.) — exchanges for their tokens
```
MCP Client (Claude) → Your OAuth Server → Upstream Service (e.g., Tidal)
│ │ │
│ 1. Discover OAuth │ │
│ 2. Register client │ │
│ 3. Authorize │──→ 4. Redirect to │
│ │ upstream login ──→ │
│ │ ←── 5. Callback ──────│
│ ←── 6. Auth code │ │
│ 7. Exchange token │ │
│ 8. Call tools ─────→│──→ 9. API calls ──────→│
```
## Required endpoints
### 1. OAuth Discovery
`app/.well-known/oauth-authorization-server/route.ts`:
```typescript
import { NextResponse } from 'next/server';
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://your-domain.com';
export async function GET() {
return NextResponse.json({
issuer: SITE_URL,
authorization_endpoint: `SITE_URL/api/authorize`,
token_endpoint: `SITE_URL/api/token`,
registration_endpoint: `SITE_URL/api/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['none'],
}, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
},
});
}
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
```
### 2. Protected Resource Metadata
`app/.well-known/oauth-protected-resource/route.ts`:
```typescript
import { protectedResourceHandler, metadataCorsOptionsRequestHandler } from 'mcp-handler';
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://your-domain.com';
export const GET = protectedResourceHandler({
authServerUrls: [SITE_URL],
resourceUrl: SITE_URL,
});
export const OPTIONS = metadataCorsOptionsRequestHandler();
```
### 3. Dynamic Client Registration (RFC 7591)
MCP clients register themselves before starting the auth flow.
`app/api/register/route.ts`:
```typescript
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const clientId = crypto.randomBytes(16).toString('hex');
return NextResponse.json({
client_id: clientId,
client_name: body.client_name || 'MCP Client',
redirect_uris: body.redirect_uris || [],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
}, { status: 201 });
}
```
### 4. Authorization Endpoint
Validates the request, stores session in Redis, redirects to upstream OAuth.
`app/api/authorize/route.ts`:
```typescript
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
const params = req.nextUrl.searchParams;
const redirectUri = params.get('redirect_uri');
const state = params.get('state');
const codeChallenge = params.get('code_challenge');
if (!redirectUri || !state || !codeChallenge) {
return NextResponse.json(
{ error: 'invalid_request', error_description: 'Missing required parameters' },
{ status: 400 },
);
}
// Validate redirect_uri — allow known MCP clients
const url = new URL(redirectUri);
const isAllowed =
url.hostname === 'claude.ai' ||
url.hostname === 'claude.com' ||
url.hostname === 'api.smithery.ai' ||
url.hostname === 'localhost' ||
url.hostname === '127.0.0.1';
if (!isAllowed) {
return NextResponse.json(
{ error: 'invalid_request', error_description: 'redirect_uri not allowed' },
{ status: 400 },
);
}
// Generate PKCE for upstream OAuth
const upstreamVerifier = crypto.randomBytes(32).toString('base64url');
const upstreamChallenge = crypto
.createHash('sha256')
.update(upstreamVerifier)
.digest('base64url');
const sessionId = crypto.randomBytes(16).toString('hex');
// Store in Redis (10 min TTL)
await redis.set(`session:sessionId`, JSON.stringify({
redirectUri, state, codeChallenge,
upstreamVerifier, upstreamState: sessionId,
}), { ex: 600 });
// Redirect to upstream OAuth (replace with your service)
const upstreamUrl = new URL('https://upstream-service.com/authorize');
upstreamUrl.searchParams.set('client_id', 'YOUR_CLIENT_ID');
upstreamUrl.searchParams.set('response_type', 'code');
upstreamUrl.searchParams.set('redirect_uri', `SITE_URL/api/callback`);
upstreamUrl.searchParams.set('code_challenge', upstreamChallenge);
upstreamUrl.searchParams.set('code_challenge_method', 'S256');
upstreamUrl.searchParams.set('state', sessionId);
return NextResponse.redirect(upstreamUrl.toString());
}
```
### 5. Callback (from upstream)
`app/api/callback/route.ts`:
```typescript
export async function GET(req: NextRequest) {
const code = req.nextUrl.searchParams.get('code');
const state = req.nextUrl.searchParams.get('state');
// Look up session from Redis
const session = JSON.parse(await redis.get(`session:state`));
if (!session) return NextResponse.json({ error: 'Session expired' }, { status: 400 });
// Exchange code for upstream tokens
const tokens = await exchangeUpstreamCode(code, session.upstreamVerifier);
// Store upstream tokens in Redis (30 day TTL)
const userId = crypto.randomBytes(16).toString('hex');
await redis.set(`user:userId:tokens`, JSON.stringify(tokens), { ex: 2592000 });
// Generate our auth code for the MCP client
const mcpAuthCode = crypto.randomBytes(16).toString('hex');
await redis.set(`auth_code:mcpAuthCode`, userId, { ex: 300 });
// Clean up and redirect back to MCP client
await redis.del(`session:state`);
const redirect = new URL(session.redirectUri);
redirect.searchParams.set('code', mcpAuthCode);
redirect.searchParams.set('state', session.state);
return NextResponse.redirect(redirect.toString());
}
```
### 6. Token Exchange
`app/api/token/route.ts`:
```typescript
export async function POST(req: NextRequest) {
const body = Object.fromEntries(await req.formData());
if (body.grant_type === 'authorization_code') {
const userId = await redis.get(`auth_code:body.code`);
if (!userId) return NextResponse.json({ error: 'invalid_grant' }, { status: 400 });
await redis.del(`auth_code:body.code`);
const accessToken = crypto.randomBytes(16).toString('hex');
const refreshToken = crypto.randomBytes(16).toString('hex');
await redis.set(`mcp_token:accessToken`, userId, { ex: 86400 });
await redis.set(`refresh:refreshToken`, userId, { ex: 2592000 });
return NextResponse.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 86400,
refresh_token: refreshToken,
});
}
if (body.grant_type === 'refresh_token') {
const userId = await redis.get(`refresh:body.refresh_token`);
if (!userId) return NextResponse.json({ error: 'invalid_grant' }, { status: 400 });
// Optionally refresh upstream tokens here too
const newAccess = crypto.randomBytes(16).toString('hex');
const newRefresh = crypto.randomBytes(16).toString('hex');
await redis.set(`mcp_token:newAccess`, userId, { ex: 86400 });
await redis.set(`refresh:newRefresh`, userId, { ex: 2592000 });
return NextResponse.json({
access_token: newAccess,
token_type: 'Bearer',
expires_in: 86400,
refresh_token: newRefresh,
});
}
return NextResponse.json({ error: 'unsupported_grant_type' }, { status: 400 });
}
```
## Wrapping the MCP handler
Use `withMcpAuth` from `mcp-handler` to enforce auth on tool calls while allowing unauthenticated discovery:
```typescript
import { createMcpHandler, withMcpAuth } from 'mcp-handler';
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
const mcpHandler = createMcpHandler(/* ... */);
const verifyToken = async (_req: Request, bearerToken?: string): Promise<AuthInfo | undefined> => {
if (!bearerToken) return undefined;
const userId = await redis.get(`mcp_token:bearerToken`);
if (!userId) return undefined;
return { token: bearerToken, clientId: 'my-server', scopes: [], extra: { userId } };
};
// required: false allows initialize/tools/list without auth
// Tools check auth themselves via extra.authInfo
const handler = withMcpAuth(mcpHandler, verifyToken, {
required: false,
resourceUrl: SITE_URL,
});
```
Setting `required: false` is important — it allows MCP clients to discover tools without authenticating first. Auth is enforced at the tool level when the tool tries to access user data.
## Redis token storage schema
| Key | Value | TTL |
|-----|-------|-----|
| `session:<id>` | OAuth session (redirect_uri, state, PKCE) | 10 min |
| `auth_code:<code>` | user ID | 5 min |
| `user:<id>:tokens` | upstream access/refresh tokens | 30 days |
| `mcp_token:<token>` | user ID | 24 hours |
| `refresh:<token>` | user ID | 30 days |
## Redirect URI allowlist
At minimum, allow these hostnames in your `/api/authorize` validation:
- `claude.ai` — Claude web
- `claude.com` — Claude web (alternate)
- `api.smithery.ai` — Smithery scanning
- `localhost` / `127.0.0.1` — local development
Add more as needed for other MCP clients. Keep the validation hostname-based (not exact URL match) because clients may use different callback paths.
## Token storage with Upstash Redis
```bash
npm install @upstash/redis
```
```typescript
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
});
```
Set up Upstash via Vercel Marketplace: Project Settings → Storage → Create → Upstash Redis. The env vars are automatically added to your project.
FILE:evals/evals.json
{
"skill_name": "mcp-oauth",
"evals": [
{
"id": 1,
"prompt": "I have an MCP server on Vercel using mcp-handler. I need to add OAuth authentication so users can log in with their Spotify account before using the tools. Set up the full auth flow.",
"expected_output": "Creates all OAuth endpoints (discovery, register, authorize, callback, token), uses withMcpAuth, adds Redis token storage, includes redirect_uri validation for Claude and Smithery"
},
{
"id": 2,
"prompt": "My MCP server needs user authentication. Users should log in with GitHub OAuth before they can use any tools. The server is on Vercel with Next.js. I need the complete OAuth PKCE flow.",
"expected_output": "Creates the double OAuth architecture (server for MCP clients + client for GitHub), all well-known endpoints, dynamic client registration, withMcpAuth with required: false, Redis schema"
}
]
}
Deploy a remote MCP server on Vercel with Next.js and mcp-handler. Use this skill whenever the user wants to create an MCP server, deploy MCP to Vercel, set...
---
name: mcp-vercel
description: "Deploy a remote MCP server on Vercel with Next.js and mcp-handler. Use this skill whenever the user wants to create an MCP server, deploy MCP to Vercel, set up a remote MCP endpoint, add MCP tools to a Next.js app, or host an MCP server in the cloud. Also triggers on: 'deploy MCP', 'remote MCP', 'MCP endpoint', 'mcp-handler', 'MCP on Vercel', 'Streamable HTTP MCP', 'Claude connector backend', 'host MCP server for Claude Desktop'. Even if the user just says 'I want to make my API available to Claude' or 'set up an MCP server', use this skill."
license: MIT
metadata:
author: lucaperret
version: "1.0.0"
openclaw:
emoji: "\u25B2"
homepage: https://github.com/lucaperret/agent-skills
---
# Deploy MCP Server on Vercel
Create a production-ready remote MCP server on Vercel using Next.js and `mcp-handler`. The server communicates via Streamable HTTP and works with Claude Desktop, claude.ai, Smithery, and any MCP client.
## Why this approach
Vercel's serverless functions are ideal for MCP servers because MCP's Streamable HTTP transport is stateless — each request is independent, which maps perfectly to serverless. No persistent connections needed. The `mcp-handler` package from Vercel handles all the protocol details.
## Quick setup
### 1. Install dependencies
```bash
npm install mcp-handler @modelcontextprotocol/sdk zod
```
### 2. Create the MCP route
Create `app/api/mcp/route.ts`:
```typescript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { createMcpHandler } from 'mcp-handler';
import { z } from 'zod';
const handler = createMcpHandler(
(server: McpServer) => {
// Register your tools here
server.tool(
'example_tool',
'What this tool does — be specific',
{ query: z.string().describe('What the parameter is for') },
{ readOnlyHint: true, destructiveHint: false, title: 'Example Tool' },
async ({ query }) => ({
content: [{ type: 'text', text: `Result for: query` }],
}),
);
},
{
serverInfo: { name: 'my-server', version: '1.0.0' },
},
{
streamableHttpEndpoint: '/api/mcp',
maxDuration: 60,
},
);
export { handler as GET, handler as POST, handler as DELETE };
```
### 3. Deploy and test
```bash
vercel deploy --prod
# Verify
curl -X POST https://your-app.vercel.app/api/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}'
```
You should get back `serverInfo` with your server name and version.
## Tool design
### Safety annotations (required)
Every tool must have annotations. MCP clients use these to decide how cautiously to invoke tools.
```typescript
// Read-only (search, get, list, fetch)
{ readOnlyHint: true, destructiveHint: false, title: 'Search Items' }
// Write but not destructive (create, add, update)
{ readOnlyHint: false, destructiveHint: false, title: 'Create Item' }
// Destructive (delete, remove, overwrite)
{ readOnlyHint: false, destructiveHint: true, title: 'Delete Item' }
```
### Parameter descriptions
Every parameter needs a `.describe()` — this is how MCP clients know what to pass.
```typescript
{
query: z.string().describe('Search query text'),
limit: z.number().optional().default(10).describe('Max results to return'),
type: z.enum(['artist', 'album', 'track']).describe('Type of content'),
}
```
### MCP prompts (optional but recommended)
Prompt templates improve discoverability on Smithery and give users ready-made starting points.
```typescript
server.prompt('find_items', 'Search for items by name',
{ name: z.string().describe('Item name') },
({ name }) => ({
messages: [{
role: 'user' as const,
content: { type: 'text' as const, text: `Find name and show details` },
}],
}),
);
```
## Routing — the `streamableHttpEndpoint` gotcha
Use `streamableHttpEndpoint`, NOT `basePath`:
```typescript
// CORRECT — endpoint at /api/mcp
{ streamableHttpEndpoint: '/api/mcp' }
// WRONG — creates endpoint at /api/mcp/mcp (doubled path)
{ basePath: '/api/mcp' }
```
The `basePath` option appends `/mcp` to whatever you give it. Since your route file is already at `app/api/mcp/route.ts`, that creates `/api/mcp/mcp`.
## Vercel deployment pitfalls
### Root Directory isolation
If your Vercel project uses a Root Directory (like `site/`), the deployed function CANNOT access files outside that directory. This means `import from '../../dist/'` will fail at runtime even if it compiles locally.
**Solution:** Copy compiled files into the site directory and commit them. Use a prebuild script to keep them in sync:
```javascript
// scripts/copy-deps.js
const fs = require('fs');
const path = require('path');
const src = path.resolve(__dirname, '../../dist');
const dest = path.resolve(__dirname, '../lib/deps');
fs.mkdirSync(dest, { recursive: true });
for (const file of fs.readdirSync(src)) {
if (file.endsWith('.js') || file.endsWith('.d.ts')) {
fs.copyFileSync(path.join(src, file), path.join(dest, file));
}
}
```
Add to package.json: `"prebuild": "node scripts/copy-deps.js"`
### Serverless read-only filesystem
Vercel functions run on a read-only filesystem with no home directory. If your code writes files (sessions, temp data), wrap in try/catch:
```typescript
try {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filepath, data);
} catch {
// Serverless environment — skip filesystem writes
}
```
### Turbopack CJS/ESM mismatch
If importing CommonJS `.js` files while the parent `package.json` doesn't have `"type": "module"`, Turbopack will error. Solution: import from compiled `.js` files bundled within the site directory, not from TypeScript source files in the parent.
## Adding authentication
For OAuth-protected servers, see the `mcp-oauth` skill which covers the complete OAuth 2.0 PKCE flow with `withMcpAuth`, including dynamic client registration and token storage.
## Publishing to Smithery
After deploying, publish to [Smithery](https://smithery.ai) for broader distribution:
1. Go to https://smithery.ai/new
2. Enter your MCP server URL
3. Choose a namespace/server-id
4. Smithery scans your tools automatically
If your server requires auth, Smithery will prompt you to connect during scanning.
## Reference
- [mcp-handler](https://github.com/vercel/mcp-handler)
- [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
- [Vercel MCP docs](https://vercel.com/docs/mcp)
FILE:evals/evals.json
{
"skill_name": "mcp-vercel",
"evals": [
{
"id": 1,
"prompt": "I have a Next.js app on Vercel and I want to add an MCP endpoint so AI agents can use my API. The API has 3 endpoints: GET /recipes (list), GET /recipes/:id (detail), POST /recipes (create). Can you set it up?",
"expected_output": "Creates app/api/mcp/route.ts with createMcpHandler, registers 3 tools with proper annotations (2 readOnly, 1 write), uses streamableHttpEndpoint, installs mcp-handler and zod"
},
{
"id": 2,
"prompt": "Deploy my Express API as an MCP server on AWS Lambda with SSE transport",
"expected_output": "Should NOT trigger this skill — this is about AWS Lambda and Express, not Vercel/Next.js"
},
{
"id": 3,
"prompt": "Set up a remote MCP server with 5 tools for my recipe database on Vercel. I need: search recipes, get recipe details, create recipe, update recipe, and delete recipe. Make sure it works with Claude Desktop and Smithery.",
"expected_output": "Creates the full MCP route with 5 tools, correct safety annotations (search/get are readOnly, create/update are write, delete is destructive), includes prompt templates, mentions Smithery publishing"
}
]
}
Control Tidal music streaming from the terminal. Use when the user wants to search Tidal's catalog (artists, albums, tracks, videos, playlists), manage playl...
---
name: tidal-cli
description: "Control Tidal music streaming from the terminal. Use when the user wants to search Tidal's catalog (artists, albums, tracks, videos, playlists), manage playlists (create, rename, delete, add/remove tracks), manage library/favorites, play music, explore artist/track info, find similar artists or tracks, get personalized recommendations, or view user profile. Triggers on: music-related requests mentioning Tidal, playlist management, music search, 'play this song', 'add to my playlist', 'find this artist on Tidal', 'what playlists do I have', 'recommend me something', 'similar artists to'."
metadata:
openclaw:
requires:
bins: ["tidal-cli"]
install:
- id: node
kind: node
package: "@lucaperret/tidal-cli"
bins: ["tidal-cli"]
label: "Install tidal-cli (npm)"
---
# tidal-cli
CLI for Tidal music streaming. Search catalog, manage playlists, control library, play tracks, explore artists, discover new music.
## First-Time Setup
If `tidal-cli` is not authenticated, run auth first. This opens the user's browser for Tidal login (one-time):
```bash
tidal-cli auth
```
Credentials persist at `~/.tidal-cli/session.json` and auto-refresh.
## Search
```bash
tidal-cli --json search artist "Radiohead"
tidal-cli --json search album "OK Computer"
tidal-cli --json search track "Karma Police"
tidal-cli --json search video "Paranoid Android"
tidal-cli --json search playlist "90s Rock"
```
JSON output returns `[{id, type, name, extra: {popularity, duration, ...}}]`.
## Artist
```bash
tidal-cli --json artist info <id> # name, bio, genres, popularity
tidal-cli --json artist tracks <id> # top tracks
tidal-cli --json artist albums <id> # discography
tidal-cli --json artist similar <id> # similar artists
```
## Track
```bash
tidal-cli --json track info <id> # title, artists, album, duration, ISRC, BPM, key
tidal-cli --json track similar <id> # similar tracks
```
## Playlists
```bash
tidal-cli --json playlist list
tidal-cli --json playlist create --name "My Playlist" --desc "Description"
tidal-cli --json playlist rename --playlist-id <id> --name "New Name"
tidal-cli --json playlist delete --playlist-id <id>
tidal-cli --json playlist add-track --playlist-id <id> --track-id <track-id>
tidal-cli --json playlist remove-track --playlist-id <id> --track-id <track-id>
tidal-cli --json playlist add-album --playlist-id <id> --album-id <album-id>
```
## Library / Favorites
```bash
tidal-cli --json library add --artist-id <id>
tidal-cli --json library add --album-id <id>
tidal-cli --json library add --track-id <id>
tidal-cli --json library remove --artist-id <id>
```
## Recommendations & User
```bash
tidal-cli --json recommend # My Mixes, Discovery, New Arrivals
tidal-cli --json user profile # account info
```
## Playback
```bash
tidal-cli playback play <track-id>
tidal-cli playback play <track-id> --quality LOSSLESS
tidal-cli --json playback info <track-id>
tidal-cli --json playback url <track-id>
```
Quality options: `LOW`, `HIGH`, `LOSSLESS`, `HI_RES`.
## Agent Patterns
**Always use `--json`** for programmatic access. Place it before the subcommand.
**Search then act:**
```bash
TRACK_ID=$(tidal-cli --json search track "Bohemian Rhapsody" | jq -r '.[0].id')
tidal-cli --json playlist add-track --playlist-id <id> --track-id "$TRACK_ID"
```
**Create themed playlist:**
```bash
PL_ID=$(tidal-cli --json playlist create --name "Road Trip" | jq -r '.id')
# search and add tracks using $PL_ID
```
**Discovery workflow (search artist -> similar -> top tracks -> add to playlist):**
```bash
ARTIST_ID=$(tidal-cli --json search artist "Portishead" | jq -r '.[0].id')
SIMILAR=$(tidal-cli --json artist similar "$ARTIST_ID" | jq -r '.[0].id')
TRACK_ID=$(tidal-cli --json artist tracks "$SIMILAR" | jq -r '.[0].id')
tidal-cli --json playlist add-track --playlist-id <id> --track-id "$TRACK_ID"
```
**Cover art:** `track info` and `album info` return a `coverUrl` field (640x640 JPEG). Always show it to the user when displaying track or album details — render it as an image.
**Exit codes:** 0 = success, 1 = error, 2 = missing argument. Errors go to stderr with `Error:` prefix.
Create, read, search, and manage macOS Notes via AppleScript. Use when the user asks to take a note, jot something down, save an idea, create meeting notes,...
---
name: macos-notes
description: Create, read, search, and manage macOS Notes via AppleScript. Use when the user asks to take a note, jot something down, save an idea, create meeting notes, read a note, search notes, or anything involving Apple Notes on macOS. Triggers on requests like "note this down", "save this as a note", "create a note about X", "show my notes", "search my notes for X", "what did I write about X". macOS only.
license: MIT
compatibility: Requires macOS with Notes.app. Uses osascript (AppleScript) and python3 for JSON parsing.
metadata:
author: lucaperret
version: "1.0.0"
openclaw:
os: macos
emoji: "\U0001F4DD"
homepage: https://github.com/lucaperret/agent-skills
requires:
bins:
- osascript
- python3
---
# macOS Notes
Manage Apple Notes via `$SKILL_DIR/scripts/notes.sh`. Notes content is stored as HTML internally; the script accepts plain text or HTML body and returns plaintext when reading.
## Quick start
### List folders
Always list folders first to discover accounts and folder names:
```bash
"$SKILL_DIR/scripts/notes.sh" list-folders
```
Output format: `account → folder` (one per line).
### Create a note
```bash
echo '<json>' | "$SKILL_DIR/scripts/notes.sh" create-note
```
JSON fields:
| Field | Required | Default | Description |
|---|---|---|---|
| `title` | yes | - | Note title (becomes the first line / heading) |
| `body` | no | "" | Note content (plain text — converted to HTML automatically) |
| `html` | no | "" | Raw HTML body (overrides `body` if both provided) |
| `folder` | no | default folder | Folder name (from list-folders) |
| `account` | no | default account | Account name (from list-folders) |
### Read a note
```bash
echo '<json>' | "$SKILL_DIR/scripts/notes.sh" read-note
```
JSON fields:
| Field | Required | Default | Description |
|---|---|---|---|
| `name` | yes | - | Note title (exact match) |
| `folder` | no | all folders | Folder to search in |
| `account` | no | default account | Account to search in |
### List notes
```bash
echo '<json>' | "$SKILL_DIR/scripts/notes.sh" list-notes
```
JSON fields:
| Field | Required | Default | Description |
|---|---|---|---|
| `folder` | no | default folder | Folder name |
| `account` | no | default account | Account name |
| `limit` | no | 20 | Max notes to return |
### Search notes
```bash
echo '<json>' | "$SKILL_DIR/scripts/notes.sh" search-notes
```
JSON fields:
| Field | Required | Default | Description |
|---|---|---|---|
| `query` | yes | - | Text to search for in note titles |
| `account` | no | default account | Account to search in |
| `limit` | no | 10 | Max results to return |
## Interpreting natural language
Map user requests to commands:
| User says | Command | Key fields |
|---|---|---|
| "Note this down: ..." | `create-note` | `title`, `body` |
| "Save meeting notes" | `create-note` | `title: "Meeting notes — <date>"`, `body` |
| "What did I write about X?" | `search-notes` | `query: "X"` |
| "Show my notes" | `list-notes` | (defaults) |
| "Read my note about X" | `read-note` | `name: "X"` |
| "Save this in my work notes" | `create-note` | Match closest `account`/`folder` from list-folders |
## Example prompts
**"Note down the API key format: prefix_xxxx"**
```bash
echo '{"title":"API key format","body":"Format: prefix_xxxx"}' | "$SKILL_DIR/scripts/notes.sh" create-note
```
**"Show my recent notes"**
```bash
echo '{}' | "$SKILL_DIR/scripts/notes.sh" list-notes
```
**"What did I write about passwords?"**
```bash
echo '{"query":"password"}' | "$SKILL_DIR/scripts/notes.sh" search-notes
```
**"Read my note about Hinge"**
```bash
echo '{"name":"Hinge"}' | "$SKILL_DIR/scripts/notes.sh" read-note
```
**"Create a meeting summary in my iCloud notes"**
```bash
"$SKILL_DIR/scripts/notes.sh" list-folders
```
Then:
```bash
echo '{"title":"Meeting summary — 2026-02-17","body":"Discussed roadmap.\n- Q1: launch MVP\n- Q2: iterate","account":"iCloud","folder":"Notes"}' | "$SKILL_DIR/scripts/notes.sh" create-note
```
## Critical rules
1. **Always list folders first** if the user hasn't specified an account/folder — folder names are reused across accounts
2. **Specify both account and folder** when targeting a specific location — `folder: "Notes"` alone is ambiguous
3. **Password-protected notes are skipped** — the script cannot read or modify them
4. **Pass JSON via stdin** — never as a CLI argument (avoids leaking data in process list)
5. **All fields are validated** by the script (type coercion, range checks) — invalid input is rejected with an error
6. **All actions are logged** to `logs/notes.log` with timestamp, command, and note title
7. **Body uses plain text** — newlines in `body` are converted to `<br>` automatically; use `html` for rich formatting
8. **Note title = first line** — Notes.app treats the first line of the body as the note name
FILE:scripts/notes.sh
#!/bin/bash
# macOS Notes helper via AppleScript
# Usage: notes.sh <command>
#
# Commands:
# list-folders List all accounts and folders
# create-note Create a note from JSON (reads stdin)
# read-note Read a note's content from JSON (reads stdin)
# list-notes List notes in a folder from JSON (reads stdin)
# search-notes Search notes by title from JSON (reads stdin)
set -euo pipefail
# Verify required dependencies are available
for bin in osascript python3; do
command -v "$bin" >/dev/null 2>&1 || { echo "Error: $bin is required but not found" >&2; exit 1; }
done
# Ensure Notes.app is running (avoids AppleScript error -600)
# Use -x for exact match to avoid false positives (e.g. "NotesHelper")
if ! pgrep -xq "Notes"; then
open -a Notes
sleep 3
fi
LOGFILE="-$(dirname "$0")/../logs/notes.log"
# Append-only action log
log_action() {
mkdir -p "$(dirname "$LOGFILE")"
printf '%s\t%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "$2" "$3" >> "$LOGFILE"
}
# Log failures on unexpected exit
trap 'log_action "error" "-unknown" "exit code $?"' ERR
cmd="-help"
case "$cmd" in
list-folders)
osascript <<'APPLESCRIPT'
tell application "Notes"
set output to ""
repeat with a in accounts
repeat with f in folders of a
set output to output & name of a & " → " & name of f & linefeed
end repeat
end repeat
return output
end tell
APPLESCRIPT
log_action "list-folders" "-" "-"
;;
create-note)
json=$(head -c 100000)
if [ #json -ge 100000 ]; then
echo "Error: input too large (max 100KB)" >&2
exit 1
fi
validated=$(NOTE_JSON="$json" python3 << 'PYEOF'
import os, sys, json, html as html_module
try:
data = json.loads(os.environ['NOTE_JSON'])
except json.JSONDecodeError as e:
print(f"Error: invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if 'title' not in data:
print("Error: 'title' field is required", file=sys.stderr)
sys.exit(1)
try:
title = str(data['title'])
body = str(data.get('body', ''))
raw_html = str(data.get('html', ''))
folder = str(data.get('folder', ''))
account = str(data.get('account', ''))
except (ValueError, TypeError) as e:
print(f"Error: invalid field value: {e}", file=sys.stderr)
sys.exit(1)
# Safe output: replace newlines/carriage returns (must be single-line for bash read)
def safe(s):
return s.replace('\n', ' ').replace('\r', '')
# Field length limits
errors = []
if len(title) > 1000: errors.append("title must be <= 1000 characters")
if len(body) > 99000: errors.append("body must be <= 99000 characters")
if len(raw_html) > 99000: errors.append("html must be <= 99000 characters")
if len(folder) > 255: errors.append("folder must be <= 255 characters")
if len(account) > 255: errors.append("account must be <= 255 characters")
if errors:
for e in errors:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Build HTML body
if raw_html:
# Sanitize raw_html: strip newlines to prevent multi-line bash read truncation
safe_html = raw_html.replace('\n', ' ').replace('\r', '')
note_html = f"<h1>{html_module.escape(title)}</h1>{safe_html}"
elif body:
# Convert plain text to HTML: escape special chars, convert newlines
escaped = html_module.escape(body)
body_html = escaped.replace('\n', '<br>')
note_html = f"<h1>{html_module.escape(title)}</h1><p>{body_html}</p>"
else:
note_html = f"<h1>{html_module.escape(title)}</h1>"
print(safe(title))
print(safe(folder) or '-')
print(safe(account) or '-')
print(note_html)
PYEOF
)
{
read -r title
read -r folder
read -r account
# note_html may contain <br> but no real newlines (Python outputs it on one line)
read -r note_html
} <<< "$validated"
# Convert sentinel '-' back to empty string
[ "$folder" = "-" ] && folder=""
[ "$account" = "-" ] && account=""
# Build the AppleScript target dynamically but safely via argv
result=$(osascript - "$note_html" "$folder" "$account" <<'APPLESCRIPT'
on run argv
set noteHTML to item 1 of argv
set folderName to item 2 of argv
set acctName to item 3 of argv
tell application "Notes"
-- Determine target folder
if acctName is not "" and folderName is not "" then
set targetFolder to folder folderName of account acctName
else if folderName is not "" then
set targetFolder to folder folderName of default account
else
set targetFolder to default folder of default account
end if
set newNote to make new note at targetFolder with properties {body:noteHTML}
return "Note created: " & name of newNote
end tell
end run
APPLESCRIPT
)
log_action "create-note" "-default/-default" "$title"
echo "$result"
;;
read-note)
json=$(head -c 10000)
if [ #json -ge 10000 ]; then
echo "Error: input too large (max 10KB)" >&2
exit 1
fi
validated=$(NOTE_JSON="$json" python3 << 'PYEOF'
import os, sys, json
try:
data = json.loads(os.environ['NOTE_JSON'])
except json.JSONDecodeError as e:
print(f"Error: invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if 'name' not in data:
print("Error: 'name' field is required", file=sys.stderr)
sys.exit(1)
name = str(data['name'])
folder = str(data.get('folder', ''))
account = str(data.get('account', ''))
def safe(s):
return s.replace('\n', ' ').replace('\r', '')
print(safe(name))
print(safe(folder) or '-')
print(safe(account) or '-')
PYEOF
)
{
read -r name
read -r folder
read -r account
} <<< "$validated"
[ "$folder" = "-" ] && folder=""
[ "$account" = "-" ] && account=""
result=$(osascript - "$name" "$folder" "$account" <<'APPLESCRIPT'
on run argv
set noteName to item 1 of argv
set folderName to item 2 of argv
set acctName to item 3 of argv
tell application "Notes"
-- Search in the specified scope
if acctName is not "" and folderName is not "" then
set targetNotes to notes of folder folderName of account acctName
else if acctName is not "" then
set targetNotes to notes of account acctName
else if folderName is not "" then
set targetNotes to notes of folder folderName of default account
else
set targetNotes to notes of default account
end if
repeat with n in targetNotes
if name of n is noteName and not password protected of n then
set noteDate to creation date of n as string
set noteModDate to modification date of n as string
return "# " & name of n & linefeed & "Created: " & noteDate & linefeed & "Modified: " & noteModDate & linefeed & linefeed & plaintext of n
end if
end repeat
return "Error: note not found"
end tell
end run
APPLESCRIPT
)
log_action "read-note" "-default/-default" "$name"
echo "$result"
;;
list-notes)
json=$(head -c 10000)
if [ #json -ge 10000 ]; then
echo "Error: input too large (max 10KB)" >&2
exit 1
fi
validated=$(NOTE_JSON="$json" python3 << 'PYEOF'
import os, sys, json
try:
data = json.loads(os.environ['NOTE_JSON'])
except json.JSONDecodeError as e:
print(f"Error: invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
folder = str(data.get('folder', ''))
account = str(data.get('account', ''))
limit = int(data.get('limit', 20))
if limit < 1 or limit > 200:
print("Error: limit must be 1-200", file=sys.stderr)
sys.exit(1)
def safe(s):
return s.replace('\n', ' ').replace('\r', '')
print(safe(folder) or '-')
print(safe(account) or '-')
print(str(limit))
PYEOF
)
{
read -r folder
read -r account
read -r limit
} <<< "$validated"
[ "$folder" = "-" ] && folder=""
[ "$account" = "-" ] && account=""
# Defense-in-depth: verify limit is a pure integer
if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
echo "Error: limit must be an integer" >&2
exit 1
fi
result=$(osascript - "$folder" "$account" "$limit" <<'APPLESCRIPT'
on run argv
set folderName to item 1 of argv
set acctName to item 2 of argv
set noteLimit to (item 3 of argv) as integer
tell application "Notes"
-- Determine target
if acctName is not "" and folderName is not "" then
set targetNotes to notes of folder folderName of account acctName
else if acctName is not "" then
set targetNotes to notes of default folder of account acctName
else if folderName is not "" then
set targetNotes to notes of folder folderName of default account
else
set targetNotes to notes of default folder of default account
end if
set output to ""
set noteCount to 0
repeat with n in targetNotes
if noteCount ≥ noteLimit then exit repeat
if not password protected of n then
set noteDate to modification date of n as string
set isShared to ""
if shared of n then set isShared to " [shared]"
set output to output & "- " & name of n & " — " & noteDate & isShared & linefeed
set noteCount to noteCount + 1
end if
end repeat
if output is "" then
return "No notes found."
end if
return output
end tell
end run
APPLESCRIPT
)
log_action "list-notes" "-default/-default" "-"
echo "$result"
;;
search-notes)
json=$(head -c 10000)
if [ #json -ge 10000 ]; then
echo "Error: input too large (max 10KB)" >&2
exit 1
fi
validated=$(NOTE_JSON="$json" python3 << 'PYEOF'
import os, sys, json
try:
data = json.loads(os.environ['NOTE_JSON'])
except json.JSONDecodeError as e:
print(f"Error: invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if 'query' not in data:
print("Error: 'query' field is required", file=sys.stderr)
sys.exit(1)
query = str(data['query'])
account = str(data.get('account', ''))
limit = int(data.get('limit', 10))
if limit < 1 or limit > 200:
print("Error: limit must be 1-200", file=sys.stderr)
sys.exit(1)
if not query.strip():
print("Error: query must not be empty", file=sys.stderr)
sys.exit(1)
if len(query) > 500:
print("Error: query must be <= 500 characters", file=sys.stderr)
sys.exit(1)
def safe(s):
return s.replace('\n', ' ').replace('\r', '')
print(safe(query))
print(safe(account) or '-')
print(str(limit))
PYEOF
)
{
read -r query
read -r account
read -r limit
} <<< "$validated"
[ "$account" = "-" ] && account=""
if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
echo "Error: limit must be an integer" >&2
exit 1
fi
result=$(osascript - "$query" "$account" "$limit" <<'APPLESCRIPT'
on run argv
set searchQuery to item 1 of argv
set acctName to item 2 of argv
set noteLimit to (item 3 of argv) as integer
tell application "Notes"
-- Determine target account
if acctName is not "" then
set targetAcct to account acctName
else
set targetAcct to default account
end if
set output to ""
set matchCount to 0
-- Search per-folder to reliably access folder name
repeat with f in folders of targetAcct
if matchCount ≥ noteLimit then exit repeat
repeat with n in notes of f
if matchCount ≥ noteLimit then exit repeat
if not password protected of n then
if name of n contains searchQuery then
set noteDate to modification date of n as string
set output to output & "- " & name of n & " (" & name of f & ") — " & noteDate & linefeed
set matchCount to matchCount + 1
end if
end if
end repeat
end repeat
if output is "" then
return "No matching notes found"
end if
return output
end tell
end run
APPLESCRIPT
)
log_action "search-notes" "-default" "$query"
echo "$result"
;;
help|*)
echo "macOS Notes CLI"
echo ""
echo "Commands:"
echo " list-folders List all accounts and folders"
echo " create-note Create a note from JSON (reads stdin)"
echo " read-note Read a note by name from JSON (reads stdin)"
echo " list-notes List notes in a folder from JSON (reads stdin)"
echo " search-notes Search notes by title from JSON (reads stdin)"
echo ""
echo "Usage:"
echo " echo '<json>' | notes.sh create-note"
echo ""
echo "JSON fields (create-note):"
echo " title (required) Note title"
echo " body Plain text content (newlines become <br>)"
echo " html Raw HTML content (overrides body)"
echo " folder Folder name (auto-detects if omitted)"
echo " account Account name (auto-detects if omitted)"
;;
esac
Create, list, and manage macOS Calendar events via AppleScript. Use when the user asks to add a reminder, schedule an event, create a calendar entry, set a d...
---
name: macos-calendar
description: Create, list, and manage macOS Calendar events via AppleScript. Use when the user asks to add a reminder, schedule an event, create a calendar entry, set a deadline, or anything involving Apple Calendar on macOS. Triggers on requests like "remind me in 3 days", "add to my calendar", "schedule a meeting next Monday at 2pm", "create a recurring weekly event". macOS only.
license: MIT
compatibility: Requires macOS with Calendar.app. Uses osascript (AppleScript) and python3 for JSON parsing.
metadata:
author: lucaperret
version: "1.2.0"
openclaw:
os: macos
emoji: "\U0001F4C5"
homepage: https://github.com/lucaperret/agent-skills
requires:
bins:
- osascript
- python3
---
# macOS Calendar
Manage Apple Calendar events via `$SKILL_DIR/scripts/calendar.sh`. All date handling uses relative math (`current date + N * days`) to avoid locale issues (FR/EN/DE date formats).
## Quick start
### List calendars
Always list calendars first to find the correct calendar name:
```bash
"$SKILL_DIR/scripts/calendar.sh" list-calendars
```
### Create an event
```bash
echo '<json>' | "$SKILL_DIR/scripts/calendar.sh" create-event
```
JSON fields:
| Field | Required | Default | Description |
|---|---|---|---|
| `summary` | yes | - | Event title |
| `calendar` | no | first calendar | Calendar name (from list-calendars) |
| `description` | no | "" | Event notes |
| `offset_days` | no | 0 | Days from today (0=today, 1=tomorrow, 7=next week) |
| `iso_date` | no | - | Absolute date `YYYY-MM-DD` (overrides offset_days) |
| `hour` | no | 9 | Start hour (0-23) |
| `minute` | no | 0 | Start minute (0-59) |
| `duration_minutes` | no | 30 | Duration |
| `alarm_minutes` | no | 0 | Alert N minutes before (0=no alarm) |
| `all_day` | no | false | All-day event |
| `recurrence` | no | - | iCal RRULE string. See [references/recurrence.md](references/recurrence.md) |
## Interpreting natural language
Map user requests to JSON fields:
| User says | JSON |
|---|---|
| "tomorrow at 2pm" | `offset_days: 1, hour: 14` |
| "in 3 days" | `offset_days: 3` |
| "next Monday at 10am" | Calculate offset_days from today to next Monday, `hour: 10` |
| "February 25 at 3:30pm" | `iso_date: "2026-02-25", hour: 15, minute: 30` |
| "every weekday at 9am" | `hour: 9, recurrence: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"` |
| "remind me 1 hour before" | `alarm_minutes: 60` |
| "all day event on March 1" | `iso_date: "2026-03-01", all_day: true` |
For "next Monday", "next Friday" etc: compute the day offset using the current date. Use `date` command if needed:
```bash
# Days until next Monday (1=Monday)
target=1; today=$(date +%u); echo $(( (target - today + 7) % 7 ))
```
## Example prompts
These are real user prompts and the commands you should run:
**"Remind me to call the dentist in 2 days"**
```bash
"$SKILL_DIR/scripts/calendar.sh" list-calendars
```
Then:
```bash
echo '{"calendar":"Personnel","summary":"Call dentist","offset_days":2,"hour":9,"duration_minutes":15,"alarm_minutes":30}' | "$SKILL_DIR/scripts/calendar.sh" create-event
```
**"Schedule a team sync every Tuesday at 2pm with a 10-min reminder"**
```bash
echo '{"calendar":"Work","summary":"Team sync","hour":14,"duration_minutes":60,"recurrence":"FREQ=WEEKLY;BYDAY=TU","alarm_minutes":10}' | "$SKILL_DIR/scripts/calendar.sh" create-event
```
**"Block July 15 as a vacation day"**
```bash
echo '{"calendar":"Personnel","summary":"Vacances","iso_date":"2026-07-15","all_day":true}' | "$SKILL_DIR/scripts/calendar.sh" create-event
```
**"I have a doctor appointment next Thursday at 3:30pm, remind me 1 hour before"**
```bash
# First compute offset_days to next Thursday (4=Thursday)
target=4; today=$(date +%u); offset=$(( (target - today + 7) % 7 )); [ "$offset" -eq 0 ] && offset=7
```
Then:
```bash
echo "{\"calendar\":\"Personnel\",\"summary\":\"Doctor appointment\",\"offset_days\":$offset,\"hour\":15,\"minute\":30,\"duration_minutes\":60,\"alarm_minutes\":60}" | "$SKILL_DIR/scripts/calendar.sh" create-event
```
**"Set up a daily standup at 9am on weekdays for the next 4 weeks"**
```bash
echo '{"calendar":"Work","summary":"Daily standup","hour":9,"duration_minutes":15,"recurrence":"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=20"}' | "$SKILL_DIR/scripts/calendar.sh" create-event
```
**"Add a biweekly 1-on-1 with my manager on Fridays at 11am"**
```bash
echo '{"calendar":"Work","summary":"1-on-1 Manager","hour":11,"duration_minutes":30,"recurrence":"FREQ=WEEKLY;INTERVAL=2;BYDAY=FR","alarm_minutes":5}' | "$SKILL_DIR/scripts/calendar.sh" create-event
```
## Critical rules
1. **Always list calendars first** if the user hasn't specified one — calendars marked `[read-only]` cannot be used for event creation
2. **Never use hardcoded date strings** in AppleScript — always use `offset_days` or `iso_date`
3. **Confirm the calendar name** with the user if multiple personal calendars exist
4. **Never target a `[read-only]` calendar** — the script will reject it with an error
5. **For recurring events**, consult [references/recurrence.md](references/recurrence.md) for RRULE syntax
6. **Pass JSON via stdin** — never as a CLI argument (avoids leaking data in process list)
7. **All fields are validated** by the script (type coercion, range checks, format validation) — invalid input is rejected with an error message
8. **All actions are logged** to `logs/calendar.log` with timestamp, command, calendar, and summary
FILE:references/recurrence.md
# iCal Recurrence Rules (RRULE)
Apple Calendar uses standard iCal RRULE format for recurring events.
## Common patterns
| Pattern | RRULE |
|---|---|
| Daily | `FREQ=DAILY;INTERVAL=1` |
| Every weekday | `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR` |
| Weekly | `FREQ=WEEKLY;INTERVAL=1` |
| Biweekly | `FREQ=WEEKLY;INTERVAL=2` |
| Monthly (same date) | `FREQ=MONTHLY;INTERVAL=1` |
| Monthly (e.g. 2nd Tuesday) | `FREQ=MONTHLY;BYDAY=2TU` |
| Yearly | `FREQ=YEARLY;INTERVAL=1` |
## Limiting recurrence
- End after N occurrences: add `COUNT=10`
- End by date: add `UNTIL=20261231T000000Z`
## Examples
- Every Monday and Wednesday: `FREQ=WEEKLY;BYDAY=MO,WE`
- First Friday of every month: `FREQ=MONTHLY;BYDAY=1FR`
- Every 3 days for 5 times: `FREQ=DAILY;INTERVAL=3;COUNT=5`
FILE:scripts/calendar.sh
#!/bin/bash
# macOS Calendar helper via AppleScript
# Usage: calendar.sh <command>
#
# Commands:
# list-calendars List all available calendars
# create-event Create an event from JSON (reads stdin)
set -euo pipefail
# Verify required dependencies are available
for bin in osascript python3; do
command -v "$bin" >/dev/null 2>&1 || { echo "Error: $bin is required but not found" >&2; exit 1; }
done
# Ensure Calendar.app is running (avoids AppleScript error -600)
if ! pgrep -q "Calendar"; then
open -a Calendar
sleep 2
fi
LOGFILE="-$(dirname "$0")/../logs/calendar.log"
# SR-004: Append-only action log
log_action() {
mkdir -p "$(dirname "$LOGFILE")"
printf '%s\t%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "$2" "$3" >> "$LOGFILE"
}
cmd="-help"
case "$cmd" in
list-calendars)
osascript -e 'tell application "Calendar"
set output to ""
repeat with c in calendars
if writable of c then
set output to output & name of c & linefeed
else
set output to output & name of c & " [read-only]" & linefeed
end if
end repeat
return output
end tell'
log_action "list-calendars" "-" "-"
;;
create-event)
# Read JSON from stdin (avoids exposing sensitive data in process list)
json=$(cat)
# Validate, normalize, and extract all fields in a single Python call.
# Outputs tab-separated values on one line.
# Tabs and newlines in string values are replaced with spaces for safe parsing.
# JSON is passed via environment variable (not pipe) because the heredoc
# already occupies stdin — a pipe would be silently discarded by bash.
validated=$(CALENDAR_JSON="$json" python3 << 'PYEOF'
import os, sys, json
try:
data = json.loads(os.environ['CALENDAR_JSON'])
except json.JSONDecodeError as e:
print(f"Error: invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if 'summary' not in data:
print("Error: 'summary' field is required", file=sys.stderr)
sys.exit(1)
try:
summary = str(data['summary'])
calendar = str(data.get('calendar', ''))
description = str(data.get('description', ''))
recurrence = str(data.get('recurrence', ''))
iso_date = str(data.get('iso_date', ''))
offset_days = int(data.get('offset_days', 0))
hour = int(data.get('hour', 9))
minute = int(data.get('minute', 0))
duration_min = int(data.get('duration_minutes', 30))
alarm_min = int(data.get('alarm_minutes', 0))
all_day = bool(data.get('all_day', False))
except (ValueError, TypeError) as e:
print(f"Error: invalid field value: {e}", file=sys.stderr)
sys.exit(1)
# Range checks
errors = []
if not 0 <= hour <= 23: errors.append("hour must be 0-23")
if not 0 <= minute <= 59: errors.append("minute must be 0-59")
if duration_min < 0: errors.append("duration_minutes must be >= 0")
if alarm_min < 0: errors.append("alarm_minutes must be >= 0")
# Validate and normalize iso_date (ensure zero-padded YYYY-MM-DD)
if iso_date:
parts = iso_date.split('-')
if len(parts) != 3:
errors.append("iso_date must be YYYY-MM-DD")
else:
try:
y, m, d = int(parts[0]), int(parts[1]), int(parts[2])
if not (1 <= m <= 12 and 1 <= d <= 31 and y >= 1):
errors.append("iso_date has invalid date values")
else:
iso_date = f"{y:04d}-{m:02d}-{d:02d}"
except ValueError:
errors.append("iso_date must contain numeric values")
if errors:
for e in errors:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Safe output: replace newlines in string values (one field per line)
def safe(s):
return s.replace('\n', ' ').replace('\r', '')
fields = [
safe(summary), safe(calendar), safe(description), safe(recurrence), safe(iso_date),
str(offset_days), str(hour), str(minute), str(duration_min), str(alarm_min),
'true' if all_day else 'false'
]
for f in fields:
print(f)
PYEOF
)
# Read validated values (one field per line, handles empty fields correctly)
{
read -r summary
read -r calendar
read -r description
read -r recurrence
read -r iso_date
read -r offset_days
read -r hour
read -r minute
read -r duration_min
read -r alarm_min
read -r all_day
} <<< "$validated"
# Defense-in-depth: verify numeric fields are pure integers
for var in offset_days hour minute duration_min alarm_min; do
if ! [[ "!var" =~ ^-?[0-9]+$ ]]; then
echo "Error: $var must be an integer" >&2
exit 1
fi
done
# Auto-detect calendar if not specified
if [ -z "$calendar" ]; then
calendar=$(osascript -e 'tell application "Calendar" to get name of first calendar')
fi
# Execute via osascript with argv parameter passing.
# All user-provided strings are passed as typed parameters via "on run argv",
# never interpolated into executable AppleScript code. This prevents injection.
result=$(osascript - "$summary" "$description" "$calendar" "$recurrence" \
"$offset_days" "$hour" "$minute" "$duration_min" "$alarm_min" \
"$all_day" "$iso_date" <<'APPLESCRIPT'
on run argv
set evtSummary to item 1 of argv
set evtDescription to item 2 of argv
set calName to item 3 of argv
set evtRecurrence to item 4 of argv
set offsetDays to (item 5 of argv) as integer
set evtHour to (item 6 of argv) as integer
set evtMinute to (item 7 of argv) as integer
set durationMin to (item 8 of argv) as integer
set alarmMin to (item 9 of argv) as integer
set isAllDay to (item 10 of argv) is "true"
set isoDate to item 11 of argv
-- Calculate start date
if isoDate is not "" then
set startDate to current date
set year of startDate to (text 1 thru 4 of isoDate) as integer
set month of startDate to (text 6 thru 7 of isoDate) as integer
set day of startDate to (text 9 thru 10 of isoDate) as integer
set hours of startDate to evtHour
set minutes of startDate to evtMinute
set seconds of startDate to 0
else
set startDate to (current date) + offsetDays * days
set hours of startDate to evtHour
set minutes of startDate to evtMinute
set seconds of startDate to 0
end if
-- Create event
tell application "Calendar"
-- SR-001: Reject read-only calendars
if not (writable of calendar calName) then
error "Calendar '" & calName & "' is read-only. Choose a writable calendar."
end if
tell calendar calName
if isAllDay then
set newEvent to make new event with properties {summary:evtSummary, start date:startDate, end date:startDate, allday event:true, description:evtDescription}
else
set endDate to startDate + durationMin * minutes
set newEvent to make new event with properties {summary:evtSummary, start date:startDate, end date:endDate, description:evtDescription}
end if
-- Set recurrence if provided
if evtRecurrence is not "" then
set recurrence of newEvent to evtRecurrence
end if
-- Set alarm if provided
if alarmMin > 0 then
make new display alarm at end of newEvent with properties {trigger interval:-alarmMin}
end if
end tell
end tell
return "Event created: " & evtSummary
end run
APPLESCRIPT
)
log_action "create-event" "$calendar" "$summary"
echo "$result"
;;
help|*)
echo "macOS Calendar CLI"
echo ""
echo "Commands:"
echo " list-calendars List all calendars"
echo " create-event Create event from JSON (reads stdin)"
echo ""
echo "Usage:"
echo " echo '<json>' | calendar.sh create-event"
echo ""
echo "JSON fields:"
echo " summary (required) Event title"
echo " calendar Calendar name (auto-detects if omitted)"
echo " description Event notes"
echo " offset_days Days from today (default: 0)"
echo " iso_date Absolute date YYYY-MM-DD (overrides offset_days)"
echo " hour Start hour 0-23 (default: 9)"
echo " minute Start minute 0-59 (default: 0)"
echo " duration_minutes Duration in minutes (default: 30)"
echo " alarm_minutes Alert before event in minutes (0=none)"
echo " all_day true/false (default: false)"
echo " recurrence iCal RRULE (e.g. FREQ=WEEKLY;BYDAY=TU)"
;;
esac