diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4fe29dd --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Circle MCP Server — Environment Configuration +# +# Copy this file to .env and fill in your values. +# The .env file is gitignored and must never be committed. + +# Required: Your Circle Admin API token +# Obtain from: Circle Admin > Settings > API +CIRCLE_API_TOKEN=***REDACTED*** + +# Optional: Circle API base URL (defaults to https://app.circle.so) +# Only change this if you have a custom Circle deployment. +# CIRCLE_BASE_URL=https://app.circle.so diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b7830dd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + validate: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18, 20, 22] + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Smoke tests + run: npx tsx test/smoke.ts diff --git a/.gitignore b/.gitignore index 109dd6d..d1db257 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,31 @@ -# Local development (server repo is separate) -app/ - # Secrets .env .env.* +!.env.example # Node node_modules/ dist/ build/ +# Local development (docs and samples that should stay out) +app/ +artifacts/samples/ +**/artifacts/samples/ + +# Test evidence (may contain token prefixes and live data) +test/evidence/ + +# Smithery build artifacts +.smithery/ +circle-mcp.mcpb + # OS .DS_Store +Thumbs.db -# Samples may contain PII -artifacts/samples/ -**/artifacts/samples/ +# IDE +.vscode/ +.idea/ +*.swp +*.swo diff --git a/.mcpbignore b/.mcpbignore new file mode 100644 index 0000000..0ec834d --- /dev/null +++ b/.mcpbignore @@ -0,0 +1,27 @@ +# Source code and development files +src/ +test/ +tsconfig.json +.github/ + +# Type declarations (not needed at runtime) +dist/*.d.ts +dist/**/*.d.ts + +# Smithery-specific files +smithery.yaml +smithery-config-schema.json +.smithery/ +SMITHERY_READINESS.md + +# Deployment configs for other platforms +railway.json + +# Development documentation +CONTRIBUTING.md +CHANGELOG.md +docs/ + +# Environment files +.env +.env.example diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7863946 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,121 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0] — 2026-03-11 + +Safe Content Operations release. Three write tools enabling post creation, post updates, and comment creation — the first mutation capabilities in the server. + +### Added + +- **circle_create_post** — Create a new post in a specified space (name, body, space_id required; optional status, slug, is_comments_enabled, skip_notifications, user_email) +- **circle_update_post** — Update an existing post by ID (post_id required; all other fields optional). Note: published posts cannot be reverted to draft status +- **circle_create_comment** — Create a comment on a post (post_id, body required; optional user_email). Known 401 limitation with admin API tokens — permission-aware error handling included +- Safe mutation infrastructure: `mutate()` method with zero-retry policy, separate from read path +- `buildMutationResponse()` envelope for consistent write tool output +- Permission-aware error handling for comment creation (401/403 → actionable workaround message) +- HTTP request type schemas (`CreatePostRequest`, `UpdatePostRequest`, `CreateCommentRequest`) with Zod validation +- MCP write tool annotations: `readOnlyHint: false`, `destructiveHint: false` on all write tools +- 38 new offline tests (95 total), covering mutation infrastructure, schemas, handlers, and error formatting + +### Implementation Qualities + +- No-retry policy for mutations — `mutate()` has zero retry to prevent duplicate writes +- Flat endpoint pattern (`POST /api/admin/v2/posts`, not nested) — live-proven against Circle API +- Flat payload pattern (no resource key wrapper) — live-proven against Circle API +- `idempotentHint: true` on update, `idempotentHint: false` on create operations +- Comment creation includes graceful degradation: 401 returns structured workaround guidance instead of raw error +- All write tool schemas use `.describe()` for agent discoverability +- `body` field empty on mutation responses is documented — use GET after mutation for populated body + +### Known Limitations + +- All v0.1.0 and v0.2.0 limitations still apply +- Comment creation (`circle_create_comment`) returns 401 with admin API tokens — a Circle API permission restriction, not a server bug +- Published posts cannot be reverted to draft status via `circle_update_post` (Circle returns 400) +- Mutation responses return `body: ""` — the API does not populate `body` on write responses. Use `circle_get_post` after mutation for full content +- No delete/archive operations — v0.3.0 is non-destructive by design +- No file/image upload support +- No event/live-stream management +- No member role or permission modification tools + +--- + +## [0.2.0] — 2026-03-11 + +Community Intelligence release. Seven new read-only tools expanding coverage to comments, topics, community metadata, space groups, and two derived analytics tools. + +### Added + +- **circle_list_comments** — List comments on a post with pagination +- **circle_get_comment** — Retrieve a single comment by numeric ID +- **circle_list_topics** — List topics (tags) in the community with pagination +- **circle_get_community** — Retrieve community-level metadata (name, slug, settings) +- **circle_list_space_groups** — List space groups with their contained space IDs +- **circle_detect_unanswered_posts** — Scan posts with zero comments to surface unanswered questions (derived, multi-call) +- **circle_community_health** — Point-in-time health snapshot: space count, post count, member count, top spaces by activity (derived, multi-call) +- Zod `.strict()` input validation on all 7 new tool schemas +- `structuredContent` with `computation` metadata on derived tools for transparency +- Live validation script (`test/live-v020.ts`) with machine-readable evidence output +- 21 new offline tests (57 total), 7 new live tests (19 total) + +### Implementation Qualities + +- All 13 tools share consistent annotations (`readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`) +- Derived tools aggregate multiple API calls with transparent `computation` metadata +- "Unanswered" heuristic (`comments_count === 0`) documented and caveated in tool description +- Community health snapshot is point-in-time, not historical trending — properly caveated +- MCP runtime handshake confirms v0.2.0 and 13-tool listing + +### Known Limitations + +- All v0.1.0 limitations still apply +- `circle_detect_unanswered_posts` uses `comments_count === 0` as heuristic — may not account for deleted comments +- `circle_community_health` is a point-in-time snapshot, not a trend — no historical comparison +- `circle_get_community` may return `url: "undefined"` for communities without a custom domain (Circle API data issue) + +--- + +## [0.1.0] — 2026-03-11 + +Initial release. Six read-only tools for querying a Circle.so community via the Admin API v2. + +### Added + +- **circle_list_spaces** — List spaces with pagination and sorting (7 sort options) +- **circle_get_space** — Retrieve a single space by numeric ID +- **circle_list_posts** — List posts with filtering by space, status, and text search +- **circle_get_post** — Retrieve a single post by numeric ID (includes full TipTap body) +- **circle_list_members** — List community members with status filtering +- **circle_search** — Search across the community (returns lightweight summary objects) +- MCP stdio transport for Claude Desktop and MCP Inspector integration +- Zod `.strict()` input validation on all 6 tool schemas +- Response truncation guard at 100,000 characters with structured truncation notices +- `structuredContent` in all tool responses (never truncated, machine-parseable) +- Retry with exponential backoff for transient failures (429, 500, 502, 503) +- `Retry-After` header support on 429 responses +- Error normalization with actionable messages for all HTTP status codes +- Fail-fast environment validation (`CIRCLE_API_TOKEN` required at startup) +- 36 offline smoke tests covering schemas, response builder, truncation, and error handling +- 12 live smoke tests against the Circle API +- Claude Desktop integration documentation (production + development configs) + +### Implementation Qualities + +- Zero external HTTP dependencies — uses Node.js native `fetch` (Node 18+) +- Strict TypeScript with no `any` types +- Consistent tool annotations (`readOnlyHint`, `idempotentHint`, `destructiveHint`, `openWorldHint`) +- All tools share `buildToolResponse()` for consistent output shape +- MCP SDK compatibility verified via `tools/list` JSON-RPC handshake + +### Known Limitations + +- Read-only: no create, update, or delete operations +- No cumulative rate-limit tracking (individual 429s handled by retry) +- Search returns summary objects, not full resources +- Page-based pagination only (no cursor pagination) +- TipTap body always included in list posts responses (use smaller `per_page` to manage size) +- No streaming support diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f74b346..3a4603b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,20 +2,21 @@ Thanks for your interest in Circle MCP. -## Where to Contribute +## Where to contribute -- **Server code, tools, and tests** — contribute to [circle-mcp-server](https://github.com/iamnortey/circle-mcp-server). See its [CONTRIBUTING.md](https://github.com/iamnortey/circle-mcp-server/blob/main/CONTRIBUTING.md). -- **Documentation fixes** — open a PR in this repository. +- **Server code, tools, and tests** — open a PR in this repository against `main`. Source lives under `src/`, tests under `test/`. +- **Documentation fixes** — open a PR with the doc change. -## Documentation Changes +## Code changes 1. Fork the repo and create a branch from `main`. 2. Make your changes. Keep PRs focused. -3. Open a pull request with a clear description. +3. Run `npm run validate` (typecheck + build + smoke tests) before pushing. +4. Open a pull request with a clear description. -## Reporting Issues +## Reporting issues -Open an issue at [github.com/iamnortey/circle-mcp-server/issues](https://github.com/iamnortey/circle-mcp-server/issues) for server bugs. For documentation issues, open an issue in this repo. +Open an issue at [github.com/iamnortey/circle-mcp/issues](https://github.com/iamnortey/circle-mcp/issues) for bugs (server or documentation). ## Security diff --git a/README.md b/README.md index 47d6f15..28a3c32 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,8 @@ Log in to your Circle community as an admin. Go to **Settings** > **API** > **Ge ### 2. Clone and build ```bash -git clone https://github.com/iamnortey/circle-mcp-server.git -cd circle-mcp-server +git clone https://github.com/iamnortey/circle-mcp.git +cd circle-mcp npm install && npm run build ``` diff --git a/SECURITY.md b/SECURITY.md index fc967b7..84ecc32 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,4 +15,4 @@ I will acknowledge receipt within 48 hours and aim to provide a fix or mitigatio ## Scope -This policy covers the documentation and configuration in this repository. For vulnerabilities in the Circle MCP Server code itself, report to the [server repository](https://github.com/iamnortey/circle-mcp-server/security). +This policy covers the source code, configuration, and documentation in this repository. Report vulnerabilities to the email above or via [GitHub Security Advisories](https://github.com/iamnortey/circle-mcp/security/advisories). diff --git a/SMITHERY_READINESS.md b/SMITHERY_READINESS.md new file mode 100644 index 0000000..6a98064 --- /dev/null +++ b/SMITHERY_READINESS.md @@ -0,0 +1,120 @@ +# Smithery Readiness Assessment + +> **Date:** 2026-03-13 +> **Server:** circle-so-mcp v0.3.0 +> **Listing:** [smithery.ai/servers/iamnortey/circle-so-mcp](https://smithery.ai/servers/iamnortey/circle-so-mcp) + +--- + +## Current Status + +| Area | Status | Notes | +|------|--------|-------| +| Published to Smithery | Done | Release `edbf09cd` accepted | +| Config schema attached | Done | `circleApiToken` (required), `circleBaseUrl` (optional) | +| Capability scan | Done | 16 tools detected, name + version confirmed | +| Connection settings UI | Done | Users prompted for token on connect | +| Hosted MCP endpoint | Done | `https://circle-so-mcp--iamnortey.run.tools` | +| Per-request config forwarding | Done | Query params extracted on new session, passed to `createServer()` | +| Railway deployment | Done | `https://circle-mcp-server-production.up.railway.app/mcp` | +| README updated | Done | Smithery is first quick-start option | + +## Blockers + +**None.** The listing is ready to be made public. + +## Remaining Gaps (Non-blocking) + +| Gap | Impact | Recommendation | +|-----|--------|----------------| +| Listing title/description | Smithery marketplace discovery | Update via Smithery dashboard (see below) | +| Tags/categories | Search visibility | Add via Smithery dashboard | +| Resources/Prompts not implemented | Scan warning (expected) | Not needed for v0.3 — suppress warning is cosmetic | +| No logo/icon | Visual presence | Add a simple icon via Smithery dashboard | + +--- + +## Recommended Listing Copy + +Update these directly in the Smithery dashboard at `smithery.ai/servers/iamnortey/circle-so-mcp`: + +### Title + +``` +Circle Community Server +``` + +### Short Description + +``` +Read, monitor, and manage your Circle.so community through Claude. 16 tools for spaces, posts, members, comments, topics, health snapshots, and content creation. +``` + +### Use Cases + +1. **Community monitoring** — Detect unanswered posts and surface engagement gaps +2. **Health checks** — Get instant community snapshots (member count, post volume, space activity) +3. **Content audit** — Find draft posts, search across spaces, audit topic coverage +4. **Content creation** — Create and update posts through natural language +5. **Cross-space search** — Search the entire community with type and space filtering + +### Recommended Tags + +``` +community, circle, circle-so, social, content-management, analytics, monitoring +``` + +### Recommended Category + +``` +Communication & Social +``` + +--- + +## Config Schema (Already Attached) + +```json +{ + "type": "object", + "required": ["circleApiToken"], + "properties": { + "circleApiToken": { + "type": "string", + "title": "Circle API Token", + "description": "Circle.so Admin API token (v2). Required. Obtain from Circle Admin > Settings > API." + }, + "circleBaseUrl": { + "type": "string", + "title": "Circle Base URL", + "default": "https://app.circle.so", + "description": "Circle API base URL. Override only for custom or proxy deployments." + } + } +} +``` + +--- + +## Architecture for Smithery + +``` +Smithery UI → user enters circleApiToken + │ + ▼ +Smithery proxy → forwards token as query param + │ + ▼ +Hosted server (Railway) → /mcp?circleApiToken=xxx + │ + ▼ +HTTP handler extracts query params on new session + │ + ▼ +createServer({ apiToken, baseUrl }) → per-session CircleClient + │ + ▼ +Circle Admin API v2 (HTTPS) +``` + +Each Smithery user gets their own session with their own token. Tokens are not persisted between sessions. diff --git a/docs/distribution/REMOTE_DISTRIBUTION_QUICKSTART.md b/docs/distribution/REMOTE_DISTRIBUTION_QUICKSTART.md index 6d5467d..5d866cf 100644 --- a/docs/distribution/REMOTE_DISTRIBUTION_QUICKSTART.md +++ b/docs/distribution/REMOTE_DISTRIBUTION_QUICKSTART.md @@ -58,8 +58,8 @@ Most users should install locally instead of using the remote endpoint. See the Quick version: 1. **Node.js >= 18** and a **Circle Admin API token** -2. Clone: `git clone https://github.com/iamnortey/circle-mcp-server.git` -3. Build: `cd circle-mcp-server && npm install && npm run build` +2. Clone: `git clone https://github.com/iamnortey/circle-mcp.git` +3. Build: `cd circle-mcp && npm install && npm run build` 4. Configure Claude Code or Claude Desktop with the stdio transport Local users provide their own Circle API token via the `CIRCLE_API_TOKEN` environment variable. @@ -154,9 +154,9 @@ See [PROMPT_STARTER_PACK.md](../product/PROMPT_STARTER_PACK.md) for the full pro | Need | Action | |------|--------| -| Bug report | [GitHub Issues](https://github.com/iamnortey/circle-mcp-server/issues) | +| Bug report | [GitHub Issues](https://github.com/iamnortey/circle-mcp/issues) | | Connection help | Check `/health` endpoint first, then verify MCP client config | -| Feature request | [GitHub Issues](https://github.com/iamnortey/circle-mcp-server/issues) | +| Feature request | [GitHub Issues](https://github.com/iamnortey/circle-mcp/issues) | | Circle API issues | Check [Circle platform status](https://status.circle.so) | | Token issues | Regenerate in Circle Admin > Settings > API | diff --git a/docs/distribution/SMITHERY_PUBLISHING_PACKAGE.md b/docs/distribution/SMITHERY_PUBLISHING_PACKAGE.md index bb11460..eeddef6 100644 --- a/docs/distribution/SMITHERY_PUBLISHING_PACKAGE.md +++ b/docs/distribution/SMITHERY_PUBLISHING_PACKAGE.md @@ -89,7 +89,7 @@ Client configuration: "mcpServers": { "circle": { "command": "node", - "args": ["/path/to/circle-mcp-server/dist/index.js"], + "args": ["/path/to/circle-mcp/dist/index.js"], "env": { "CIRCLE_API_TOKEN": "YOUR_CIRCLE_API_TOKEN" } diff --git a/docs/product/CLIENT_QUICKSTART.md b/docs/product/CLIENT_QUICKSTART.md index 044efd0..547623e 100644 --- a/docs/product/CLIENT_QUICKSTART.md +++ b/docs/product/CLIENT_QUICKSTART.md @@ -47,8 +47,8 @@ Quick version: ```bash # Clone and build -git clone https://github.com/iamnortey/circle-mcp-server.git -cd circle-mcp-server +git clone https://github.com/iamnortey/circle-mcp.git +cd circle-mcp npm install && npm run build # Get the absolute path to the built server @@ -57,7 +57,7 @@ echo "$(pwd)/dist/index.js" # Add to Claude Code (replace token and path) claude mcp add --transport stdio \ --env CIRCLE_API_TOKEN=your-token \ - circle -- node /absolute/path/to/circle-mcp-server/dist/index.js + circle -- node /absolute/path/to/circle-mcp/dist/index.js ``` Verify by starting Claude Code and running `/mcp` to see the Circle tools. @@ -69,8 +69,8 @@ Verify by starting Claude Code and running `/mcp` to see the Circle tools. ### Step 1: Clone and Build ```bash -git clone https://github.com/iamnortey/circle-mcp-server.git -cd circle-mcp-server +git clone https://github.com/iamnortey/circle-mcp.git +cd circle-mcp npm install npm run build ``` @@ -89,7 +89,7 @@ Add the Circle MCP server: "mcpServers": { "circle": { "command": "node", - "args": ["/absolute/path/to/circle-mcp-server/dist/index.js"], + "args": ["/absolute/path/to/circle-mcp/dist/index.js"], "env": { "CIRCLE_API_TOKEN": "your-circle-admin-api-token" } @@ -98,7 +98,7 @@ Add the Circle MCP server: } ``` -Replace `/absolute/path/to/` with the actual path on your machine (use `pwd` in the `circle-mcp-server` directory to find it). +Replace `/absolute/path/to/` with the actual path on your machine (use `pwd` in the `circle-mcp` directory to find it). ### Step 3: Restart Claude Desktop @@ -254,5 +254,5 @@ Circle enforces a rate limit of 2,000 requests per 5 minutes per IP. Most tool c - **More prompt ideas:** See [PROMPT_STARTER_PACK.md](./PROMPT_STARTER_PACK.md) - **Full Claude Code guide:** See [CLAUDE_CODE_INSTALL.md](../self-serve/CLAUDE_CODE_INSTALL.md) -- **Full tool reference:** See the [server README](../../app/circle-mcp-server/README.md) -- **Report issues:** [github.com/iamnortey/circle-mcp-server/issues](https://github.com/iamnortey/circle-mcp-server/issues) +- **Full tool reference:** See [tools.md](../tools.md) +- **Report issues:** [github.com/iamnortey/circle-mcp/issues](https://github.com/iamnortey/circle-mcp/issues) diff --git a/docs/self-serve/CLAUDE_CODE_INSTALL.md b/docs/self-serve/CLAUDE_CODE_INSTALL.md index bea361e..2dea73f 100644 --- a/docs/self-serve/CLAUDE_CODE_INSTALL.md +++ b/docs/self-serve/CLAUDE_CODE_INSTALL.md @@ -57,10 +57,10 @@ Open your terminal and run these commands: ```bash # Clone the repository -git clone https://github.com/iamnortey/circle-mcp-server.git +git clone https://github.com/iamnortey/circle-mcp.git # Enter the project directory -cd circle-mcp-server +cd circle-mcp # Install dependencies npm install @@ -81,7 +81,7 @@ This runs type-checking, builds, and runs 95 offline tests. No API token needed. ## Step 3: Connect to Claude Code -You need the **absolute path** to the built server file. Get it by running this in the `circle-mcp-server` directory: +You need the **absolute path** to the built server file. Get it by running this in the `circle-mcp` directory: ```bash echo "$(pwd)/dist/index.js" @@ -92,19 +92,19 @@ Copy that path. Now add the MCP server to Claude Code: ```bash claude mcp add --transport stdio \ --env CIRCLE_API_TOKEN=your-circle-api-token-here \ - circle -- node /absolute/path/to/circle-mcp-server/dist/index.js + circle -- node /absolute/path/to/circle-mcp/dist/index.js ``` **Replace two things:** - `your-circle-api-token-here` — paste your Circle API token from Step 1 -- `/absolute/path/to/circle-mcp-server/dist/index.js` — paste the path from the `echo` command above +- `/absolute/path/to/circle-mcp/dist/index.js` — paste the path from the `echo` command above **Example with a real path (macOS):** ```bash claude mcp add --transport stdio \ --env CIRCLE_API_TOKEN=sk_live_abc123def456 \ - circle -- node /Users/yourname/circle-mcp-server/dist/index.js + circle -- node /Users/yourname/circle-mcp/dist/index.js ``` **Scope options:** @@ -114,7 +114,7 @@ claude mcp add --transport stdio \ ```bash claude mcp add --transport stdio --scope user \ --env CIRCLE_API_TOKEN=your-circle-api-token-here \ - circle -- node /absolute/path/to/circle-mcp-server/dist/index.js + circle -- node /absolute/path/to/circle-mcp/dist/index.js ``` --- @@ -201,7 +201,7 @@ Create a draft post in the Announcements space titled "Test Post" with the body claude mcp remove circle claude mcp add --transport stdio \ --env CIRCLE_API_TOKEN=your-actual-token \ - circle -- node /absolute/path/to/circle-mcp-server/dist/index.js + circle -- node /absolute/path/to/circle-mcp/dist/index.js ``` ### 401 Unauthorized on tool calls @@ -224,8 +224,8 @@ claude mcp add --transport stdio \ **Cause:** The project was not built, or the path is wrong. **Fix:** -1. Make sure you ran `npm run build` in the `circle-mcp-server` directory -2. Verify the path: `ls /your/path/to/circle-mcp-server/dist/index.js` — it should exist +1. Make sure you ran `npm run build` in the `circle-mcp` directory +2. Verify the path: `ls /your/path/to/circle-mcp/dist/index.js` — it should exist 3. Use the absolute path, not a relative one ### Circle server not showing in `/mcp` @@ -294,7 +294,7 @@ claude mcp remove circle claude mcp remove circle claude mcp add --transport stdio \ --env CIRCLE_API_TOKEN=your-new-token \ - circle -- node /absolute/path/to/circle-mcp-server/dist/index.js + circle -- node /absolute/path/to/circle-mcp/dist/index.js ``` ### Check what's configured @@ -309,5 +309,5 @@ claude mcp get circle ## Next Steps - **More prompt ideas:** See [PROMPT_STARTER_PACK.md](../product/PROMPT_STARTER_PACK.md) -- **Full tool reference:** See the [server README](../../app/circle-mcp-server/README.md) -- **Report issues:** [github.com/iamnortey/circle-mcp-server/issues](https://github.com/iamnortey/circle-mcp-server/issues) +- **Full tool reference:** See [tools.md](../tools.md) +- **Report issues:** [github.com/iamnortey/circle-mcp/issues](https://github.com/iamnortey/circle-mcp/issues) diff --git a/docs/self-serve/CLAUDE_DESKTOP_INSTALL.md b/docs/self-serve/CLAUDE_DESKTOP_INSTALL.md index 6e8a312..0e26708 100644 --- a/docs/self-serve/CLAUDE_DESKTOP_INSTALL.md +++ b/docs/self-serve/CLAUDE_DESKTOP_INSTALL.md @@ -70,10 +70,10 @@ Open your terminal and run these commands: ```bash # Clone the repository -git clone https://github.com/iamnortey/circle-mcp-server.git +git clone https://github.com/iamnortey/circle-mcp.git # Enter the project directory -cd circle-mcp-server +cd circle-mcp # Install dependencies npm install @@ -94,7 +94,7 @@ This runs type-checking, builds, and runs 95 offline tests. No API token needed. ## Step 3: Find Your Absolute Path -Claude Desktop needs the **absolute path** to the built server file. Get it by running this command inside the `circle-mcp-server` directory: +Claude Desktop needs the **absolute path** to the built server file. Get it by running this command inside the `circle-mcp` directory: ```bash echo "$(pwd)/dist/index.js" @@ -102,9 +102,9 @@ echo "$(pwd)/dist/index.js" **Copy the output.** It will look something like: -- **macOS:** `/Users/yourname/circle-mcp-server/dist/index.js` -- **Windows (Git Bash):** `/c/Users/yourname/circle-mcp-server/dist/index.js` -- **Windows (native):** `C:\Users\yourname\circle-mcp-server\dist\index.js` +- **macOS:** `/Users/yourname/circle-mcp/dist/index.js` +- **Windows (Git Bash):** `/c/Users/yourname/circle-mcp/dist/index.js` +- **Windows (native):** `C:\Users\yourname\circle-mcp\dist\index.js` You'll paste this path into the config file in the next step. @@ -155,7 +155,7 @@ If the file is **empty or doesn't exist**, paste this entire block: "mcpServers": { "circle": { "command": "node", - "args": ["/absolute/path/to/circle-mcp-server/dist/index.js"], + "args": ["/absolute/path/to/circle-mcp/dist/index.js"], "env": { "CIRCLE_API_TOKEN": "your-circle-admin-api-token" } @@ -174,7 +174,7 @@ If the file **already has content** (other MCP servers configured), add the `cir }, "circle": { "command": "node", - "args": ["/absolute/path/to/circle-mcp-server/dist/index.js"], + "args": ["/absolute/path/to/circle-mcp/dist/index.js"], "env": { "CIRCLE_API_TOKEN": "your-circle-admin-api-token" } @@ -184,7 +184,7 @@ If the file **already has content** (other MCP servers configured), add the `cir ``` **Replace two things:** -- `/absolute/path/to/circle-mcp-server/dist/index.js` — paste the path from Step 3 +- `/absolute/path/to/circle-mcp/dist/index.js` — paste the path from Step 3 - `your-circle-admin-api-token` — paste your Circle API token from Step 1 **Example with real values (macOS):** @@ -194,7 +194,7 @@ If the file **already has content** (other MCP servers configured), add the `cir "mcpServers": { "circle": { "command": "node", - "args": ["/Users/yourname/circle-mcp-server/dist/index.js"], + "args": ["/Users/yourname/circle-mcp/dist/index.js"], "env": { "CIRCLE_API_TOKEN": "sk_live_abc123def456" } @@ -326,8 +326,8 @@ Make sure the token is a non-empty string and the JSON is valid. Save and restar **Cause:** The path to `dist/index.js` is wrong, or the project was not built. **Fix:** -1. Verify the file exists: `ls /your/path/to/circle-mcp-server/dist/index.js` -2. If it doesn't exist, run `npm run build` in the `circle-mcp-server` directory +1. Verify the file exists: `ls /your/path/to/circle-mcp/dist/index.js` +2. If it doesn't exist, run `npm run build` in the `circle-mcp` directory 3. Ensure you're using the **absolute path**, not a relative one 4. On Windows, use forward slashes in the JSON: `"C:/Users/..."` (not `"C:\\Users\\..."`) @@ -430,5 +430,5 @@ If you need to change your Circle API token: ## Next Steps - **More prompt ideas:** See [PROMPT_STARTER_PACK.md](../product/PROMPT_STARTER_PACK.md) -- **Full tool reference:** See the [server README](../../app/circle-mcp-server/README.md) -- **Report issues:** [github.com/iamnortey/circle-mcp-server/issues](https://github.com/iamnortey/circle-mcp-server/issues) +- **Full tool reference:** See [tools.md](../tools.md) +- **Report issues:** [github.com/iamnortey/circle-mcp/issues](https://github.com/iamnortey/circle-mcp/issues) diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..7590246 --- /dev/null +++ b/manifest.json @@ -0,0 +1,85 @@ +{ + "manifest_version": "0.3", + "name": "circle-so-mcp", + "display_name": "Circle MCP", + "version": "0.3.1", + "description": "AI operating interface for Circle.so communities. Inspect spaces, posts, members, and comments. Detect unanswered questions, generate health snapshots, and create or update content — all through natural language.", + "long_description": "Circle MCP connects AI assistants to the Circle.so Admin API, giving community operators natural-language access to their community data and operations.\n\n**16 tools across four tiers:**\n\n- **Core Read:** List and inspect spaces, posts, members, and search across the community.\n- **Extended Read:** Comments, topics, space groups, and community metadata.\n- **Intelligence:** Unanswered post detection and community health snapshots — derived insights that aggregate multiple API calls.\n- **Write:** Create posts, update posts, and create comments. No delete, archive, or moderation operations.\n\n**Key properties:**\n\n- Runs locally on your machine. No data passes through third-party infrastructure.\n- Stateless — no database, no cache, no persistent storage.\n- Zero telemetry, analytics, or error reporting.\n- All inputs validated with strict Zod schemas.\n- Automatic retry with backoff on transient read failures. Zero-retry on writes to prevent duplicates.\n- Responses include both human-readable text and structured JSON.", + "author": { + "name": "Isaac Nortey", + "email": "isaac@nortey.dev", + "url": "https://github.com/iamnortey" + }, + "repository": { + "type": "git", + "url": "https://github.com/iamnortey/circle-mcp" + }, + "homepage": "https://github.com/iamnortey/circle-mcp", + "documentation": "https://github.com/iamnortey/circle-mcp/blob/main/docs/tools.md", + "support": "https://github.com/iamnortey/circle-mcp/issues", + "icon": "icon.png", + "server": { + "type": "node", + "entry_point": "dist/index.js", + "mcp_config": { + "command": "node", + "args": [ + "${__dirname}/dist/index.js" + ], + "env": { + "CIRCLE_API_TOKEN": "${user_config.circle_api_token}" + } + } + }, + "tools": [ + { "name": "circle_list_spaces", "description": "List spaces in the Circle community with pagination and sorting." }, + { "name": "circle_get_space", "description": "Retrieve a single space by its numeric ID." }, + { "name": "circle_list_posts", "description": "List posts with optional filtering by space, status, and text search." }, + { "name": "circle_get_post", "description": "Retrieve a single post by its numeric ID, including the full rich-text body." }, + { "name": "circle_list_members", "description": "List community members with optional status filtering." }, + { "name": "circle_search", "description": "Search across the community for posts, members, comments, and spaces." }, + { "name": "circle_list_comments", "description": "List comments on a post or within a space." }, + { "name": "circle_get_comment", "description": "Retrieve a single comment by its numeric ID." }, + { "name": "circle_list_topics", "description": "List topics (tags) available in the community." }, + { "name": "circle_get_community", "description": "Retrieve community-level metadata including name, slug, and settings." }, + { "name": "circle_list_space_groups", "description": "List space groups and the space IDs contained in each group." }, + { "name": "circle_detect_unanswered_posts", "description": "Scan for posts with zero comments, surfacing unanswered questions." }, + { "name": "circle_community_health", "description": "Generate a point-in-time community health snapshot aggregating space, post, and member data." }, + { "name": "circle_create_post", "description": "Create a new post in a specified space. Defaults to draft status." }, + { "name": "circle_update_post", "description": "Update an existing post by ID. Only provided fields are changed." }, + { "name": "circle_create_comment", "description": "Create a comment on a post." } + ], + "keywords": [ + "circle", + "circle.so", + "community", + "community-management", + "mcp", + "admin", + "posts", + "members", + "spaces", + "comments", + "health-check", + "content-operations" + ], + "license": "MIT", + "privacy_policies": [ + "https://nortey.dev/circle-mcp/privacy" + ], + "user_config": { + "circle_api_token": { + "type": "string", + "title": "Circle Admin API Token", + "description": "Your Circle.so Admin API token. Generate one in Circle Admin > Settings > API. This token has admin-level access to your community.", + "sensitive": true, + "required": true + } + }, + "compatibility": { + "platforms": ["darwin", "win32", "linux"], + "runtimes": { + "node": ">=18.0.0" + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..135b798 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1724 @@ +{ + "name": "circle-so-mcp", + "version": "0.3.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "circle-so-mcp", + "version": "0.3.1", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "zod": "^3.24.4" + }, + "bin": { + "circle-so-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^25.4.0", + "tsx": "^4.19.4", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz", + "integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", + "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", + "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..57bfc71 --- /dev/null +++ b/package.json @@ -0,0 +1,80 @@ +{ + "name": "circle-so-mcp", + "version": "0.3.1", + "description": "MCP server for Circle.so Admin API v2 — community intelligence and content management tools for spaces, posts, members, comments, topics, search, and derived analytics", + "author": "Isaac Nortey", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "circle-so-mcp": "dist/index.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./server": { + "types": "./dist/server.d.ts", + "default": "./dist/server.js" + }, + "./smithery": { + "types": "./dist/smithery.d.ts", + "default": "./dist/smithery.js" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/iamnortey/circle-mcp.git" + }, + "homepage": "https://github.com/iamnortey/circle-mcp#readme", + "bugs": { + "url": "https://github.com/iamnortey/circle-mcp/issues" + }, + "files": [ + "dist", + "smithery.yaml", + "README.md", + "LICENSE", + ".env.example" + ], + "scripts": { + "start": "node dist/index.js", + "start:http": "node dist/http.js", + "dev": "tsx watch src/index.ts", + "dev:http": "tsx watch src/http.ts", + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit", + "prebuild": "npm run clean", + "postbuild": "node --input-type=module -e \"import{readFileSync,writeFileSync}from'fs';const f='dist/index.js',c=readFileSync(f,'utf8');if(!c.startsWith('#!'))writeFileSync(f,'#!/usr/bin/env node\\n'+c);\"", + "test": "npm run build && npx tsx test/smoke.ts", + "test:http": "npm run build && npx tsx test/smoke-http.ts", + "test:live": "npx tsx test/smoke-live.ts", + "validate": "npm run typecheck && npm run build && npx tsx test/smoke.ts", + "prepublishOnly": "npm run validate" + }, + "keywords": [ + "mcp", + "circle", + "circle-so", + "community", + "api", + "model-context-protocol", + "claude", + "smithery" + ], + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "zod": "^3.24.4" + }, + "devDependencies": { + "@types/node": "^25.4.0", + "tsx": "^4.19.4", + "typescript": "^5.8.3" + } +} diff --git a/railway.json b/railway.json new file mode 100644 index 0000000..3f3d69d --- /dev/null +++ b/railway.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "NIXPACKS", + "buildCommand": "npm install && npm run build" + }, + "deploy": { + "startCommand": "npm run start:http", + "healthcheckPath": "/health", + "healthcheckTimeout": 5, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + } +} diff --git a/smithery-config-schema.json b/smithery-config-schema.json new file mode 100644 index 0000000..3bb5a65 --- /dev/null +++ b/smithery-config-schema.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "required": ["circleApiToken"], + "properties": { + "circleApiToken": { + "type": "string", + "title": "Circle API Token", + "description": "Circle.so Admin API token (v2). Required. Obtain from Circle Admin > Settings > API." + }, + "circleBaseUrl": { + "type": "string", + "title": "Circle Base URL", + "default": "https://app.circle.so", + "description": "Circle API base URL. Override only for custom or proxy deployments." + } + } +} diff --git a/smithery.yaml b/smithery.yaml new file mode 100644 index 0000000..d683fba --- /dev/null +++ b/smithery.yaml @@ -0,0 +1,28 @@ +# Smithery configuration for circle-mcp +# Publication mode: Local (stdio) — no HTTP, no Docker +# See: https://smithery.ai/docs/build/project-config/smithery.yaml + +startCommand: + type: stdio + configSchema: + type: object + required: + - circleApiToken + properties: + circleApiToken: + type: string + description: "Circle.so Admin API token (v2). Required." + circleBaseUrl: + type: string + default: "https://app.circle.so" + description: "Circle API base URL. Override only for custom/proxy deployments." + commandFunction: + |- + (config) => ({ + command: 'node', + args: ['dist/index.js'], + env: { + CIRCLE_API_TOKEN: config.circleApiToken, + ...(config.circleBaseUrl ? { CIRCLE_BASE_URL: config.circleBaseUrl } : {}) + } + }) diff --git a/src/clients/circle-client.ts b/src/clients/circle-client.ts new file mode 100644 index 0000000..9571ae5 --- /dev/null +++ b/src/clients/circle-client.ts @@ -0,0 +1,404 @@ +/** + * Circle Admin API v2 client. + * + * Encapsulates all API calls with typed parameters and return values. + * Uses CircleHttpClient for transport — no direct fetch calls here. + * + * v0.1.0: 6 read-only methods (spaces, posts, members, search) + * v0.2.0: 11 read-only methods (+comments, topics, community, space groups) + * v0.3.0: 14 methods (+createPost, updatePost, createComment) + * Write methods use FLAT payloads and FLAT endpoints per live proving. + * + * @see docs/contracts/endpoint-inventory.md for endpoint details + */ + +import { CircleHttpClient } from "../lib/http.js"; +import type { CircleEnvConfig } from "../config/env.js"; +import type { + CircleSpace, + CirclePost, + CircleMember, + CircleSearchResult, + CircleComment, + CircleTopic, + CircleCommunity, + CircleSpaceGroupDetail, + CreatePostRequest, + UpdatePostRequest, + CreateCommentRequest, +} from "../types/circle.js"; +import type { CirclePaginatedResponse } from "../types/common.js"; + +// --------------------------------------------------------------------------- +// Parameter interfaces +// --------------------------------------------------------------------------- + +export interface ListSpacesParams { + page?: number; + per_page?: number; + sort?: string; +} + +export interface ListPostsParams { + page?: number; + per_page?: number; + space_id?: number; + space_group_id?: number; + status?: string; + search_text?: string; + sort?: string; +} + +export interface ListMembersParams { + page?: number; + per_page?: number; + status?: string; +} + +export interface SearchParams { + query: string; + page?: number; + per_page?: number; + type?: string; +} + +// v0.2.0 parameter interfaces + +export interface ListCommentsParams { + page?: number; + per_page?: number; + post_id?: number; + space_id?: number; +} + +export interface ListTopicsParams { + page?: number; + per_page?: number; +} + +export interface ListSpaceGroupsParams { + page?: number; + per_page?: number; +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/** + * Typed client for the Circle Admin API v2. + * + * v0.1–v0.2: Read-only methods. + * v0.3: Adds write methods (createPost, updatePost, createComment). + */ +export class CircleClient { + private readonly http: CircleHttpClient; + + constructor(config: CircleEnvConfig) { + this.http = new CircleHttpClient(config); + } + + // ------------------------------------------------------------------------- + // Spaces + // ------------------------------------------------------------------------- + + /** + * List spaces in the community. + * + * GET /api/admin/v2/spaces + */ + async listSpaces( + params: ListSpacesParams = {} + ): Promise> { + return this.http.get>( + "/api/admin/v2/spaces", + { + page: params.page, + per_page: params.per_page, + sort: params.sort, + } + ); + } + + /** + * Get a single space by ID. + * + * GET /api/admin/v2/spaces/{id} + * + * Returns the unwrapped space object (not in a pagination envelope). + */ + async getSpace(id: number): Promise { + return this.http.get(`/api/admin/v2/spaces/${id}`); + } + + // ------------------------------------------------------------------------- + // Posts + // ------------------------------------------------------------------------- + + /** + * List posts across the community, optionally filtered by space. + * + * GET /api/admin/v2/posts + */ + async listPosts( + params: ListPostsParams = {} + ): Promise> { + return this.http.get>( + "/api/admin/v2/posts", + { + page: params.page, + per_page: params.per_page, + space_id: params.space_id, + space_group_id: params.space_group_id, + status: params.status, + search_text: params.search_text, + sort: params.sort, + } + ); + } + + /** + * Get a single post by ID. + * + * GET /api/admin/v2/posts/{id} + * + * Returns the unwrapped post object including full tiptap_body. + */ + async getPost(id: number): Promise { + return this.http.get(`/api/admin/v2/posts/${id}`); + } + + // ------------------------------------------------------------------------- + // Members + // ------------------------------------------------------------------------- + + /** + * List community members. + * + * GET /api/admin/v2/community_members + */ + async listMembers( + params: ListMembersParams = {} + ): Promise> { + return this.http.get>( + "/api/admin/v2/community_members", + { + page: params.page, + per_page: params.per_page, + status: params.status, + } + ); + } + + // ------------------------------------------------------------------------- + // Search + // ------------------------------------------------------------------------- + + /** + * Search across the community using advanced search. + * + * GET /api/admin/v2/advanced_search + * + * Returns lightweight summary objects — NOT full resource objects. + * Use getSpace/getPost for full details on individual results. + */ + async search( + params: SearchParams + ): Promise> { + return this.http.get>( + "/api/admin/v2/advanced_search", + { + query: params.query, + page: params.page, + per_page: params.per_page, + type: params.type, + } + ); + } + + // ------------------------------------------------------------------------- + // Comments (v0.2.0) + // ------------------------------------------------------------------------- + + /** + * List comments, optionally filtered by post or space. + * + * GET /api/admin/v2/comments + * Verified: ✅ 200 (0 records in test community) + */ + async listComments( + params: ListCommentsParams = {} + ): Promise> { + return this.http.get>( + "/api/admin/v2/comments", + { + page: params.page, + per_page: params.per_page, + post_id: params.post_id, + space_id: params.space_id, + } + ); + } + + /** + * Get a single comment by ID. + * + * GET /api/admin/v2/comments/{id} + * Source: Swagger spec (not individually live-tested) + */ + async getComment(id: number): Promise { + return this.http.get(`/api/admin/v2/comments/${id}`); + } + + // ------------------------------------------------------------------------- + // Topics (v0.2.0) + // ------------------------------------------------------------------------- + + /** + * List all topics in the community. + * + * GET /api/admin/v2/topics + * Verified: ✅ 200 (0 records in test community) + */ + async listTopics( + params: ListTopicsParams = {} + ): Promise> { + return this.http.get>( + "/api/admin/v2/topics", + { + page: params.page, + per_page: params.per_page, + } + ); + } + + // ------------------------------------------------------------------------- + // Community (v0.2.0) + // ------------------------------------------------------------------------- + + /** + * Get community-level metadata. + * + * GET /api/admin/v2/community + * Verified: ✅ 200 (returns community info for id 495139) + * + * Returns the unwrapped community object (not paginated). + */ + async getCommunity(): Promise { + return this.http.get("/api/admin/v2/community"); + } + + // ------------------------------------------------------------------------- + // Space Groups (v0.2.0) + // ------------------------------------------------------------------------- + + /** + * List all space groups in the community. + * + * GET /api/admin/v2/space_groups + * Verified: ✅ 200 (4 groups in test community) + */ + async listSpaceGroups( + params: ListSpaceGroupsParams = {} + ): Promise> { + return this.http.get>( + "/api/admin/v2/space_groups", + { + page: params.page, + per_page: params.per_page, + } + ); + } + + // ========================================================================= + // Write methods (v0.3.0) + // ========================================================================= + + // ------------------------------------------------------------------------- + // Posts — write (v0.3.0) + // ------------------------------------------------------------------------- + + /** + * Create a new post in a space. + * + * POST /api/admin/v2/posts + * + * Live proving (Prompt 12) confirmed: + * - Flat endpoint (NOT /spaces/{id}/posts — that 404s) + * - Flat payload (NO `post` wrapper — wrapper causes 500) + * - `space_id` is included in the request body + * - Response envelope: `{ message, post: { ... } }` + * + * **No auto-retry** — mutations are not idempotent. + */ + async createPost( + spaceId: number, + request: CreatePostRequest + ): Promise { + const envelope = await this.http.post<{ message: string; post: CirclePost }>( + "/api/admin/v2/posts", + { ...request, space_id: spaceId } + ); + return envelope.post; + } + + /** + * Update an existing post. + * + * PUT /api/admin/v2/posts/{id} + * + * Live proving (Prompt 12) confirmed: + * - Flat payload (NO `post` wrapper — wrapper is silently ignored) + * - Response envelope: `{ success, message, post: { ... } }` + * - Note: `status` cannot be changed from "published" back to "draft" + * + * **No auto-retry** — mutations are not idempotent. + */ + async updatePost( + postId: number, + request: UpdatePostRequest + ): Promise { + const envelope = await this.http.put<{ + success: boolean; + message: string; + post: CirclePost; + }>( + `/api/admin/v2/posts/${postId}`, + { ...request } + ); + return envelope.post; + } + + // ------------------------------------------------------------------------- + // Comments — write (v0.3.0) + // ------------------------------------------------------------------------- + + /** + * Create a comment on a post. + * + * POST /api/admin/v2/comments + * + * Live proving (Prompt 12) findings: + * - Flat endpoint (NOT /posts/{id}/comments — that 404s with HTML) + * - Flat payload with `post_id` in body (NO `comment` wrapper) + * - ⚠ UNPROVEN: endpoint returns 401 "You cannot perform this action" + * in test environment. The endpoint EXISTS (returns JSON, not HTML 404), + * but the admin token may lack comment-write permissions. + * This method implements the best-guess contract for Prompt 13 proving. + * + * **No auto-retry** — mutations are not idempotent. + */ + async createComment( + postId: number, + request: CreateCommentRequest + ): Promise { + const envelope = await this.http.post<{ + message: string; + comment: CircleComment; + }>( + "/api/admin/v2/comments", + { ...request, post_id: postId } + ); + return envelope.comment; + } +} diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..8972700 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,55 @@ +/** + * Environment configuration for Circle MCP Server. + * + * Reads CIRCLE_API_TOKEN and CIRCLE_BASE_URL from process.env. + * Fails fast with a clear message if the required token is missing. + */ + +export interface CircleEnvConfig { + /** Circle Admin API token (required). */ + readonly apiToken: string; + + /** Circle API base URL. Defaults to https://app.circle.so */ + readonly baseUrl: string; +} + +/** + * Optional overrides for config values. + * + * When provided (e.g. from Smithery query parameters), these take + * precedence over process.env. When omitted, falls back to env vars. + */ +export interface ConfigOverrides { + apiToken?: string; + baseUrl?: string; +} + +/** + * Load and validate environment configuration. + * + * @param overrides Optional per-request values (e.g. from Smithery query params). + * When provided, they take precedence over process.env. + * @throws {Error} if CIRCLE_API_TOKEN is not set or is empty. + */ +export function loadEnvConfig(overrides?: ConfigOverrides): CircleEnvConfig { + const apiToken = + overrides?.apiToken?.trim() || process.env.CIRCLE_API_TOKEN?.trim(); + + if (!apiToken) { + throw new Error( + "CIRCLE_API_TOKEN is not set.\n" + + " Claude Code: claude mcp add --env CIRCLE_API_TOKEN=your-token ...\n" + + " Claude Desktop: set in the JSON config env block\n" + + " Direct use: CIRCLE_API_TOKEN=your-token node dist/index.js\n" + + "See .env.example for all available environment variables." + ); + } + + const baseUrl = ( + overrides?.baseUrl?.trim() || + process.env.CIRCLE_BASE_URL?.trim() || + "https://app.circle.so" + ).replace(/\/+$/, ""); // strip trailing slashes + + return { apiToken, baseUrl }; +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..0b8a67d --- /dev/null +++ b/src/http.ts @@ -0,0 +1,442 @@ +/** + * Streamable HTTP entrypoint for circle-mcp. + * + * Exposes the same 16 MCP tools as the stdio entrypoint (src/index.ts) + * over Streamable HTTP transport. Zero new runtime dependencies — uses + * the Node.js built-in `http` module and the SDK's + * StreamableHTTPServerTransport. + * + * Start: npm run start:http (PORT defaults to 3000) + * Health: GET http://localhost:3000/health + * MCP: POST http://localhost:3000/mcp (initialize + tool calls) + * GET http://localhost:3000/mcp (SSE notification stream) + * DELETE http://localhost:3000/mcp (close session) + * + * Status: PRODUCTION — both stdio and HTTP transports are fully supported. + * + * Hardening (Prompt 12/12): + * - Graceful shutdown on SIGINT / SIGTERM + * - Request body size limit (1 MB) + * - Max concurrent sessions (20, configurable via MAX_SESSIONS) + * - Session idle timeout (30 min, configurable via SESSION_TTL_MS) + * - Concise request diagnostics (no secrets logged) + * - Content-Type validation on POST + */ + +import http from "node:http"; +import { randomUUID } from "node:crypto"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { createServer } from "./server.js"; +import { loadEnvConfig } from "./config/env.js"; +import type { ConfigOverrides } from "./config/env.js"; +import { VERSION } from "./version.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Maximum request body size in bytes (1 MB). */ +const MAX_BODY_BYTES = 1_048_576; + +/** Maximum concurrent sessions (default 20). */ +const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "20", 10); + +/** Session idle timeout in ms (default 30 minutes). */ +const SESSION_TTL_MS = parseInt( + process.env.SESSION_TTL_MS || String(30 * 60 * 1000), + 10, +); + +/** Interval at which idle sessions are reaped (60 s). */ +const REAP_INTERVAL_MS = 60_000; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface Session { + transport: StreamableHTTPServerTransport; + server: ReturnType; + /** Epoch ms of the last request that touched this session. */ + lastActivity: number; +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +const sessions = new Map(); +let reapTimer: ReturnType | undefined; + +// --------------------------------------------------------------------------- +// Logging helpers — never log secrets, keep lines concise +// --------------------------------------------------------------------------- + +function log(msg: string): void { + console.error(`[circle-mcp-http] ${msg}`); +} + +function logRequest( + req: http.IncomingMessage, + status: number, + extra?: string, +): void { + const tag = extra ? ` ${extra}` : ""; + log(`${req.method} ${req.url} → ${status}${tag}`); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Parse JSON body from an IncomingMessage with a size guard. + * Rejects with a descriptive error if the body exceeds MAX_BODY_BYTES. + */ +function parseBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + req.destroy(); + reject( + new RangeError( + `Request body exceeds limit (${MAX_BODY_BYTES} bytes)`, + ), + ); + return; + } + chunks.push(chunk); + }); + + req.on("end", () => { + try { + const raw = Buffer.concat(chunks).toString("utf-8"); + resolve(raw.length > 0 ? JSON.parse(raw) : undefined); + } catch (err) { + reject(err); + } + }); + + req.on("error", reject); + }); +} + +/** Write a JSON response. */ +function jsonResponse( + res: http.ServerResponse, + status: number, + body: Record, +): void { + const payload = JSON.stringify(body); + res.writeHead(status, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }); + res.end(payload); +} + +/** Set CORS headers for cross-origin MCP client access. */ +function setCorsHeaders(res: http.ServerResponse): void { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + res.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, mcp-session-id", + ); + res.setHeader("Access-Control-Expose-Headers", "mcp-session-id"); +} + +/** Touch a session's lastActivity timestamp. */ +function touchSession(id: string): void { + const s = sessions.get(id); + if (s) s.lastActivity = Date.now(); +} + +/** Close and remove a session. */ +async function destroySession(id: string): Promise { + const s = sessions.get(id); + if (!s) return; + try { + await s.transport.close(); + } catch { + /* best-effort */ + } + sessions.delete(id); +} + +// --------------------------------------------------------------------------- +// Session reaper — evicts idle sessions +// --------------------------------------------------------------------------- + +function reapIdleSessions(): void { + const now = Date.now(); + for (const [id, session] of sessions) { + if (now - session.lastActivity > SESSION_TTL_MS) { + log(`Reaping idle session ${id.slice(0, 8)}…`); + void destroySession(id); + } + } +} + +// --------------------------------------------------------------------------- +// Route handlers +// --------------------------------------------------------------------------- + +function handleHealth( + req: http.IncomingMessage, + res: http.ServerResponse, +): void { + const body = { + status: "ok", + version: VERSION, + transport: "streamable-http", + sessions: sessions.size, + maxSessions: MAX_SESSIONS, + sessionTtlMs: SESSION_TTL_MS, + }; + jsonResponse(res, 200, body); + logRequest(req, 200); +} + +async function handleMcp( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, +): Promise { + const method = req.method?.toUpperCase(); + + // --- POST: initialize or tool call ----------------------------------------- + if (method === "POST") { + // Validate Content-Type + const ct = req.headers["content-type"] || ""; + if (!ct.includes("application/json")) { + jsonResponse(res, 415, { + error: "Unsupported Media Type — expected application/json", + }); + logRequest(req, 415); + return; + } + + let body: unknown; + try { + body = await parseBody(req); + } catch (err) { + if (err instanceof RangeError) { + jsonResponse(res, 413, { error: err.message }); + logRequest(req, 413); + return; + } + jsonResponse(res, 400, { error: "Malformed JSON body" }); + logRequest(req, 400); + return; + } + + const sessionId = req.headers["mcp-session-id"] as string | undefined; + + if (!sessionId) { + // --- New session --------------------------------------------------- + if (sessions.size >= MAX_SESSIONS) { + jsonResponse(res, 503, { + error: `Session limit reached (max ${MAX_SESSIONS})`, + }); + logRequest(req, 503, "session-limit"); + return; + } + + // Extract per-request config from query params (Smithery external URL flow). + // Falls back to process.env when absent (Railway / direct usage). + const qpToken = url.searchParams.get("circleApiToken") || undefined; + const qpBaseUrl = url.searchParams.get("circleBaseUrl") || undefined; + const overrides: ConfigOverrides | undefined = + qpToken || qpBaseUrl + ? { apiToken: qpToken, baseUrl: qpBaseUrl } + : undefined; + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }); + + const server = createServer(overrides); + await server.connect(transport); + + // handleRequest processes the initialize message and assigns sessionId + await transport.handleRequest(req, res, body); + + // Session ID is only available AFTER handleRequest completes + const newSessionId = transport.sessionId; + if (newSessionId) { + sessions.set(newSessionId, { + transport, + server, + lastActivity: Date.now(), + }); + log( + `Session created ${newSessionId.slice(0, 8)}… (${sessions.size}/${MAX_SESSIONS})`, + ); + } + + logRequest(req, res.statusCode, "new-session"); + return; + } + + // --- Existing session ------------------------------------------------ + const session = sessions.get(sessionId); + if (!session) { + jsonResponse(res, 404, { error: "Session not found" }); + logRequest(req, 404); + return; + } + touchSession(sessionId); + await session.transport.handleRequest(req, res, body); + logRequest(req, res.statusCode); + return; + } + + // --- GET: SSE notification stream ----------------------------------------- + if (method === "GET") { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId) { + jsonResponse(res, 400, { error: "Missing mcp-session-id header" }); + logRequest(req, 400); + return; + } + const session = sessions.get(sessionId); + if (!session) { + jsonResponse(res, 404, { error: "Session not found" }); + logRequest(req, 404); + return; + } + touchSession(sessionId); + await session.transport.handleRequest(req, res); + logRequest(req, res.statusCode, "sse"); + return; + } + + // --- DELETE: close session ------------------------------------------------ + if (method === "DELETE") { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId) { + jsonResponse(res, 400, { error: "Missing mcp-session-id header" }); + logRequest(req, 400); + return; + } + const session = sessions.get(sessionId); + if (!session) { + jsonResponse(res, 404, { error: "Session not found" }); + logRequest(req, 404); + return; + } + await session.transport.handleRequest(req, res); + await destroySession(sessionId); + log( + `Session closed ${sessionId.slice(0, 8)}… (${sessions.size}/${MAX_SESSIONS})`, + ); + logRequest(req, res.statusCode, "closed"); + return; + } + + // --- Unsupported method --------------------------------------------------- + jsonResponse(res, 405, { error: "Method not allowed" }); + logRequest(req, 405); +} + +// --------------------------------------------------------------------------- +// Graceful shutdown +// --------------------------------------------------------------------------- + +async function shutdown( + httpServer: http.Server, + signal: string, +): Promise { + log(`${signal} received — shutting down…`); + + // Stop accepting new connections + httpServer.close(); + + // Stop the reaper + if (reapTimer) clearInterval(reapTimer); + + // Close all sessions + const ids = [...sessions.keys()]; + await Promise.allSettled(ids.map((id) => destroySession(id))); + + log(`Shutdown complete (${ids.length} session(s) closed)`); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +async function main(): Promise { + // Fail fast if env is misconfigured — but only when not expecting + // per-request config via query params (Smithery external URL flow). + if (process.env.CIRCLE_API_TOKEN) { + loadEnvConfig(); + } else { + log( + "CIRCLE_API_TOKEN not set in env — expecting per-request config via query params (Smithery external URL mode)", + ); + } + + const port = parseInt(process.env.PORT || "3000", 10); + + const httpServer = http.createServer(async (req, res) => { + setCorsHeaders(res); + + // Handle CORS preflight + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + const url = new URL(req.url || "/", `http://localhost:${port}`); + + try { + if (url.pathname === "/health") { + handleHealth(req, res); + } else if (url.pathname === "/mcp") { + await handleMcp(req, res, url); + } else { + jsonResponse(res, 404, { error: "Not found" }); + logRequest(req, 404); + } + } catch (err) { + const message = + err instanceof Error ? err.message : "Unknown error"; + log(`Unhandled error: ${message}`); + if (!res.headersSent) { + jsonResponse(res, 500, { error: "Internal server error" }); + } + } + }); + + // Wire up graceful shutdown + process.on("SIGINT", () => void shutdown(httpServer, "SIGINT")); + process.on("SIGTERM", () => void shutdown(httpServer, "SIGTERM")); + + // Start session reaper + reapTimer = setInterval(reapIdleSessions, REAP_INTERVAL_MS); + reapTimer.unref(); // don't keep process alive just for reaping + + httpServer.listen(port, "0.0.0.0", () => { + log(`Listening on http://localhost:${port}`); + log("Endpoints:"); + log(` GET /health`); + log(` POST /mcp`); + log(` GET /mcp (SSE)`); + log(` DELETE /mcp`); + log(`Config:`); + log(` MAX_SESSIONS=${MAX_SESSIONS} SESSION_TTL_MS=${SESSION_TTL_MS} MAX_BODY=1MB`); + }); +} + +main().catch((error: unknown) => { + console.error("Fatal error starting Circle MCP Server (HTTP):", error); + process.exit(1); +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..0b73876 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,25 @@ +/** + * Entry point for the Circle MCP Server. + * + * Connects the server to stdio transport for use with MCP clients + * (Claude Desktop, MCP Inspector, etc.). + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createServer } from "./server.js"; +import { VERSION } from "./version.js"; + +async function main(): Promise { + const server = createServer(); + const transport = new StdioServerTransport(); + + await server.connect(transport); + + // Log to stderr so it doesn't interfere with MCP protocol on stdout + console.error(`Circle MCP Server v${VERSION} running on stdio`); +} + +main().catch((error: unknown) => { + console.error("Fatal error starting Circle MCP Server:", error); + process.exit(1); +}); diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..ca78023 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,162 @@ +/** + * Error types and handling utilities for the Circle MCP server. + * + * All API errors are normalized into CircleApiError with actionable messages. + * No silent failures — every error path produces a meaningful message. + */ + +/** + * Structured error for Circle API failures. + */ +export class CircleApiError extends Error { + constructor( + message: string, + public readonly statusCode: number | undefined, + public readonly endpoint: string, + public readonly cause?: unknown + ) { + super(message); + this.name = "CircleApiError"; + } +} + +/** + * Normalize any thrown value into a CircleApiError with an actionable message. + * + * Handles: + * - HTTP response errors (status codes) + * - Network/timeout errors + * - Unknown error shapes + */ +export function normalizeApiError( + error: unknown, + endpoint: string +): CircleApiError { + // Already a CircleApiError — pass through + if (error instanceof CircleApiError) { + return error; + } + + // Standard Error with a response-like shape (e.g. from fetch) + if (isResponseError(error)) { + const status = error.status ?? error.statusCode; + const message = buildStatusMessage(status, endpoint); + return new CircleApiError(message, status, endpoint, error); + } + + // Network / timeout errors + if (error instanceof TypeError && error.message.includes("fetch")) { + return new CircleApiError( + `Network error calling ${endpoint}: unable to reach Circle API. ` + + `Check your CIRCLE_BASE_URL and network connectivity.`, + undefined, + endpoint, + error + ); + } + + if (error instanceof DOMException && error.name === "AbortError") { + return new CircleApiError( + `Request to ${endpoint} timed out. The Circle API may be slow or unreachable.`, + undefined, + endpoint, + error + ); + } + + // Generic Error + if (error instanceof Error) { + return new CircleApiError( + `Error calling ${endpoint}: ${error.message}`, + undefined, + endpoint, + error + ); + } + + // Completely unknown shape + return new CircleApiError( + `Unknown error calling ${endpoint}: ${String(error)}`, + undefined, + endpoint, + error + ); +} + +/** + * Format a CircleApiError into MCP-friendly error content. + */ +export function formatErrorForMcp(error: CircleApiError): { + content: Array<{ type: "text"; text: string }>; + isError: true; +} { + return { + content: [{ type: "text" as const, text: error.message }], + isError: true, + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +interface ResponseLikeError { + status?: number; + statusCode?: number; + message?: string; +} + +function isResponseError(error: unknown): error is ResponseLikeError & Error { + if (!(error instanceof Error)) return false; + const e = error as unknown as Record; + return typeof e.status === "number" || typeof e.statusCode === "number"; +} + +function buildStatusMessage(status: number | undefined, endpoint: string): string { + switch (status) { + case 400: + return ( + `Bad request for ${endpoint} (400). ` + + `The request body or parameters are malformed. Check required fields and data types.` + ); + case 401: + return ( + `Authentication failed for ${endpoint} (401). ` + + `Verify your CIRCLE_API_TOKEN is correct and not expired.` + ); + case 403: + return ( + `Access denied for ${endpoint} (403). ` + + `Your API token may lack permissions for this resource.` + ); + case 404: + return ( + `Resource not found at ${endpoint} (404). ` + + `Verify the ID exists in your Circle community.` + ); + case 409: + return ( + `Conflict for ${endpoint} (409). ` + + `The resource may already exist or a concurrent modification occurred.` + ); + case 422: + return ( + `Invalid parameters for ${endpoint} (422). ` + + `Check the request parameters against the API documentation.` + ); + case 429: + return ( + `Rate limit exceeded for ${endpoint} (429). ` + + `Circle allows 2,000 requests per 5 minutes per IP. Wait and retry.` + ); + case 500: + case 502: + case 503: + return ( + `Circle API server error at ${endpoint} (${status}). ` + + `This is a transient issue on Circle's side. Retry after a moment.` + ); + default: + return `HTTP ${status ?? "unknown"} error calling ${endpoint}.`; + } +} diff --git a/src/lib/http.ts b/src/lib/http.ts new file mode 100644 index 0000000..f9665eb --- /dev/null +++ b/src/lib/http.ts @@ -0,0 +1,377 @@ +/** + * Lightweight HTTP client for the Circle Admin API. + * + * Uses native Node.js fetch (available in Node 18+). + * No external HTTP dependencies — keeps the server lean. + * + * Features: + * - Auth injection via Authorization: Token header + * - Conservative 30-second timeout via AbortController + * - JSON response parsing with error normalization + * - Query parameter serialization (handles nested filter objects) + * - Bounded retry with exponential backoff for transient failures (429, 5xx) + * + * v0.3.0: Added POST/PUT methods for write operations. + * - Mutations are NEVER automatically retried (not idempotent) + * - Same auth, timeout, and error handling as GET + * - JSON request body serialization with Content-Type header + */ + +import { CircleEnvConfig } from "../config/env.js"; +import { normalizeApiError, CircleApiError } from "./errors.js"; + +/** Default request timeout in milliseconds. */ +const REQUEST_TIMEOUT_MS = 30_000; + +/** + * Retry configuration for transient failures. + * + * Only safe, idempotent GET requests are retried. + * - 429 (Rate limit): Retry after backoff — Circle allows 2,000 req/5min. + * - 500/502/503 (Server errors): Transient; worth one or two retries. + * + * Backoff schedule: 1s → 2s → 4s (exponential, capped at 3 attempts total). + */ +interface RetryConfig { + /** Maximum total attempts (including the initial request). */ + maxAttempts: number; + /** Base delay in ms before first retry. Doubled on each subsequent retry. */ + baseDelayMs: number; + /** HTTP status codes that are safe to retry on GET. */ + retryableStatuses: ReadonlySet; +} + +const DEFAULT_RETRY: RetryConfig = { + maxAttempts: 3, + baseDelayMs: 1_000, + retryableStatuses: new Set([429, 500, 502, 503]), +}; + +/** + * HTTP client bound to a specific Circle environment configuration. + */ +export class CircleHttpClient { + private readonly baseUrl: string; + private readonly token: string; + + constructor(config: CircleEnvConfig) { + this.baseUrl = config.baseUrl; + this.token = config.apiToken; + } + + /** + * Perform a GET request to a Circle API endpoint with retry support. + * + * @param path - API path (e.g. "/api/admin/v2/spaces") + * @param params - Query parameters (flat key-value pairs) + * @returns Parsed JSON response + * @throws {CircleApiError} on any HTTP or network failure after exhausting retries + */ + async get( + path: string, + params?: Record + ): Promise { + const url = this.buildUrl(path, params); + const endpoint = `GET ${path}`; + + let lastError: CircleApiError | undefined; + + for (let attempt = 1; attempt <= DEFAULT_RETRY.maxAttempts; attempt++) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(url.toString(), { + method: "GET", + headers: { + Authorization: `Token ${this.token}`, + Accept: "application/json", + }, + signal: controller.signal, + }); + + if (!response.ok) { + const status = response.status; + + // Check if this is a retryable status and we have attempts remaining + if ( + DEFAULT_RETRY.retryableStatuses.has(status) && + attempt < DEFAULT_RETRY.maxAttempts + ) { + lastError = new CircleApiError( + buildHttpErrorMessage(status, endpoint), + status, + endpoint + ); + + const delayMs = getRetryDelay(attempt, status, response); + console.error( + `[circle-mcp] ${endpoint} returned ${status}, retrying in ${delayMs}ms ` + + `(attempt ${attempt}/${DEFAULT_RETRY.maxAttempts})` + ); + await sleep(delayMs); + continue; + } + + // Not retryable or out of retries + throw new CircleApiError( + buildHttpErrorMessage(status, endpoint), + status, + endpoint + ); + } + + const data = (await response.json()) as T; + return data; + } catch (error: unknown) { + // If it's already a CircleApiError from the status check above, throw it + if (error instanceof CircleApiError) { + throw error; + } + + // For network/timeout errors, retry if we have attempts left + if (attempt < DEFAULT_RETRY.maxAttempts && isTransientError(error)) { + lastError = normalizeApiError(error, endpoint); + const delayMs = DEFAULT_RETRY.baseDelayMs * Math.pow(2, attempt - 1); + console.error( + `[circle-mcp] ${endpoint} failed with transient error, retrying in ${delayMs}ms ` + + `(attempt ${attempt}/${DEFAULT_RETRY.maxAttempts}): ${lastError.message}` + ); + await sleep(delayMs); + continue; + } + + throw normalizeApiError(error, endpoint); + } finally { + clearTimeout(timeout); + } + } + + // Should not reach here, but if we do, throw the last error + throw ( + lastError ?? + new CircleApiError( + `Failed after ${DEFAULT_RETRY.maxAttempts} attempts: ${endpoint}`, + undefined, + endpoint + ) + ); + } + + // ------------------------------------------------------------------------- + // Write methods (v0.3.0) + // ------------------------------------------------------------------------- + + /** + * Perform a POST request to a Circle API endpoint. + * + * **No automatic retry.** Mutations are not idempotent — retrying could + * create duplicate resources. Callers must handle failures explicitly. + * + * @param path - API path (e.g. "/api/admin/v2/spaces/123/posts") + * @param body - JSON request body + * @returns Parsed JSON response + * @throws {CircleApiError} on any HTTP or network failure + */ + async post(path: string, body: Record): Promise { + return this.mutate("POST", path, body); + } + + /** + * Perform a PUT request to a Circle API endpoint. + * + * **No automatic retry.** Mutations are not idempotent — retrying could + * produce unintended side effects. Callers must handle failures explicitly. + * + * @param path - API path (e.g. "/api/admin/v2/posts/456") + * @param body - JSON request body + * @returns Parsed JSON response + * @throws {CircleApiError} on any HTTP or network failure + */ + async put(path: string, body: Record): Promise { + return this.mutate("PUT", path, body); + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + /** + * Shared implementation for POST and PUT requests. + * + * Key differences from GET: + * - Sends JSON body with Content-Type header + * - **No retry logic** — mutations are not safe to auto-retry + * - Same auth injection and timeout behavior + */ + private async mutate( + method: "POST" | "PUT", + path: string, + body: Record + ): Promise { + const url = this.buildUrl(path); + const endpoint = `${method} ${path}`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(url.toString(), { + method, + headers: { + Authorization: `Token ${this.token}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + if (!response.ok) { + // Attempt to read error body for richer diagnostics + let errorBody: string | undefined; + try { + errorBody = await response.text(); + } catch { + // Ignore — error body is best-effort + } + + const message = buildHttpErrorMessage(response.status, endpoint); + const enriched = errorBody + ? `${message} Response body: ${errorBody.slice(0, 500)}` + : message; + + throw new CircleApiError(enriched, response.status, endpoint); + } + + const data = (await response.json()) as T; + return data; + } catch (error: unknown) { + if (error instanceof CircleApiError) { + throw error; + } + throw normalizeApiError(error, endpoint); + } finally { + clearTimeout(timeout); + } + } + + /** + * Build a fully-qualified URL with query parameters. + * + * Strips undefined values from params to allow optional parameters. + */ + private buildUrl( + path: string, + params?: Record + ): URL { + const url = new URL(path, this.baseUrl); + + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + } + + return url; + } +} + +// --------------------------------------------------------------------------- +// Retry helpers +// --------------------------------------------------------------------------- + +/** + * Calculate retry delay with exponential backoff. + * + * For 429 responses, respects the Retry-After header if present. + * Otherwise uses exponential backoff: baseDelay * 2^(attempt-1). + */ +function getRetryDelay( + attempt: number, + status: number, + response: Response +): number { + // Respect Retry-After header for 429 + if (status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) { + const seconds = parseInt(retryAfter, 10); + if (!isNaN(seconds) && seconds > 0 && seconds <= 60) { + return seconds * 1_000; + } + } + } + + // Exponential backoff: 1s, 2s, 4s... + return DEFAULT_RETRY.baseDelayMs * Math.pow(2, attempt - 1); +} + +/** + * Determine if an error is likely transient and worth retrying. + */ +function isTransientError(error: unknown): boolean { + // Timeout (AbortError) + if (error instanceof DOMException && error.name === "AbortError") { + return true; + } + + // Network errors (fetch failures) + if (error instanceof TypeError && error.message.includes("fetch")) { + return true; + } + + return false; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// --------------------------------------------------------------------------- +// Error messages +// --------------------------------------------------------------------------- + +function buildHttpErrorMessage(status: number, endpoint: string): string { + switch (status) { + case 400: + return ( + `Bad request for ${endpoint} (400). ` + + `The request body or parameters are malformed. Check required fields and data types.` + ); + case 401: + return ( + `Authentication failed for ${endpoint} (401). ` + + `Verify your CIRCLE_API_TOKEN is correct and not expired.` + ); + case 403: + return ( + `Access denied for ${endpoint} (403). ` + + `Your API token may lack permissions for this resource.` + ); + case 404: + return ( + `Resource not found at ${endpoint} (404). ` + + `Verify the ID exists in your Circle community.` + ); + case 409: + return ( + `Conflict for ${endpoint} (409). ` + + `The resource may already exist or a concurrent modification occurred.` + ); + case 422: + return ( + `Invalid parameters for ${endpoint} (422). ` + + `Check the request parameters against the API documentation.` + ); + case 429: + return ( + `Rate limit exceeded for ${endpoint} (429). ` + + `Circle allows 2,000 requests per 5 minutes per IP. Wait and retry.` + ); + default: + return `HTTP ${status} error calling ${endpoint}.`; + } +} diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts new file mode 100644 index 0000000..edcb790 --- /dev/null +++ b/src/lib/pagination.ts @@ -0,0 +1,39 @@ +/** + * Pagination utilities for Circle API responses. + * + * Circle uses page-based (not cursor-based) pagination with a consistent + * envelope across all list endpoints. + * + * @see docs/contracts/endpoint-inventory.md §Pagination Contract + */ + +import type { CirclePaginatedResponse } from "../types/common.js"; + +/** + * Summary of pagination state, included in tool responses + * so the LLM can decide whether to fetch more pages. + */ +export interface PaginationSummary { + page: number; + per_page: number; + has_next_page: boolean; + total_count: number; + total_pages: number; + records_on_page: number; +} + +/** + * Extract a concise pagination summary from a Circle paginated response. + */ +export function extractPaginationSummary( + response: CirclePaginatedResponse +): PaginationSummary { + return { + page: response.page, + per_page: response.per_page, + has_next_page: response.has_next_page, + total_count: response.count, + total_pages: response.page_count, + records_on_page: response.records.length, + }; +} diff --git a/src/lib/responses.ts b/src/lib/responses.ts new file mode 100644 index 0000000..26761da --- /dev/null +++ b/src/lib/responses.ts @@ -0,0 +1,76 @@ +/** + * Response formatting utilities for MCP tool handlers. + * + * Provides a unified way to build tool responses with: + * - Truncation guard (prevents oversized text payloads) + * - Structured content (for clients that support it) + * - Consistent shape across all tools + * + * v0.3.0: Added buildMutationResponse for write operations. + */ + +import { toStructuredContent, prepareSafeText } from "../types/common.js"; +import type { MutationResult } from "../types/circle.js"; + +/** + * MCP tool response shape returned by all tool handlers. + * + * The index signature `[key: string]: unknown` is required because the MCP SDK's + * `registerTool` expects the handler to return `{ [x: string]: unknown; content: ...; }`. + * Without it, TypeScript rejects concrete interfaces at the call site. + */ +export interface ToolResponse { + [key: string]: unknown; + content: Array<{ type: "text"; text: string }>; + structuredContent: Record; +} + +/** + * Build a safe, truncation-guarded tool response from a data object. + * + * Used by read tool handlers to ensure consistent response shape. + * The text content is truncated if it exceeds RESPONSE_TEXT_LIMIT, + * while structuredContent passes through untruncated (clients parse it directly). + * + * @param data - The result data to serialize + * @returns MCP tool response with text content and structured content + */ +export function buildToolResponse(data: unknown): ToolResponse { + const { text } = prepareSafeText(data); + + return { + content: [{ type: "text" as const, text }], + structuredContent: toStructuredContent(data), + }; +} + +/** + * Build a tool response for a mutation (create/update) operation. + * + * Wraps the API response in a MutationResult envelope that tells the LLM: + * - What operation was performed + * - Whether it succeeded + * - The resulting resource + * - Which endpoint was called + * + * Uses the same truncation guard and structured content as read responses. + * + * @param operation - "create" or "update" + * @param data - The resource returned by the API + * @param endpoint - The API endpoint that was called (for diagnostics) + * @returns MCP tool response wrapping a MutationResult + */ +export function buildMutationResponse( + operation: "create" | "update", + data: T, + endpoint: string +): ToolResponse { + const result: MutationResult = { + operation, + success: true, + data, + endpoint, + }; + + return buildToolResponse(result); +} diff --git a/src/schemas/inputs.ts b/src/schemas/inputs.ts new file mode 100644 index 0000000..5a9b2d5 --- /dev/null +++ b/src/schemas/inputs.ts @@ -0,0 +1,456 @@ +/** + * Zod input schemas for all MCP tools. + * + * v0.1.0: 6 schemas (spaces, posts, members, search) + * v0.2.0: 13 schemas (+comments, topics, community, space groups, derived intelligence) + * v0.3.0: 16 schemas (+createPost, updatePost, createComment) + * + * Each schema validates and documents the parameters that an LLM can pass + * to the corresponding tool. Descriptions are tuned for agent discoverability. + * + * @see docs/contracts/endpoint-inventory.md for parameter details + */ + +import { z } from "zod"; + +// --------------------------------------------------------------------------- +// Shared pagination fields +// --------------------------------------------------------------------------- + +const pageParam = z + .number() + .int() + .positive() + .optional() + .describe("Page number (1-based). Defaults to 1."); + +const perPageParam = z + .number() + .int() + .positive() + .max(100) + .optional() + .describe("Results per page (1-100). Defaults to 10."); + +// --------------------------------------------------------------------------- +// circle_list_spaces +// --------------------------------------------------------------------------- + +export const ListSpacesInputSchema = z + .object({ + page: pageParam, + per_page: perPageParam, + sort: z + .enum([ + "active", + "oldest", + "alphabetical", + "likes", + "latest_updated", + "oldest_updated", + "latest_profile_confirmed", + ]) + .optional() + .describe("Sort order for spaces. Defaults to server default."), + }) + .strict() + .describe("Parameters for listing spaces in the community."); + +export type ListSpacesInput = z.infer; + +// --------------------------------------------------------------------------- +// circle_get_space +// --------------------------------------------------------------------------- + +export const GetSpaceInputSchema = z + .object({ + space_id: z + .number() + .int() + .positive() + .describe("The numeric ID of the space to retrieve."), + }) + .strict() + .describe("Parameters for retrieving a single space by ID."); + +export type GetSpaceInput = z.infer; + +// --------------------------------------------------------------------------- +// circle_list_posts +// --------------------------------------------------------------------------- + +export const ListPostsInputSchema = z + .object({ + page: pageParam, + per_page: perPageParam, + space_id: z + .number() + .int() + .positive() + .optional() + .describe("Filter posts to a specific space by its numeric ID."), + space_group_id: z + .number() + .int() + .positive() + .optional() + .describe("Filter posts to a specific space group by its numeric ID."), + status: z + .enum(["draft", "published", "scheduled", "all"]) + .optional() + .describe("Filter by post status. Defaults to published."), + search_text: z + .string() + .optional() + .describe("Text search within post content."), + sort: z + .enum([ + "oldest", + "latest", + "alphabetical", + "likes", + "latest_updated", + "oldest_updated", + ]) + .optional() + .describe("Sort order for posts."), + }) + .strict() + .describe("Parameters for listing posts in the community."); + +export type ListPostsInput = z.infer; + +// --------------------------------------------------------------------------- +// circle_get_post +// --------------------------------------------------------------------------- + +export const GetPostInputSchema = z + .object({ + post_id: z + .number() + .int() + .positive() + .describe("The numeric ID of the post to retrieve."), + }) + .strict() + .describe("Parameters for retrieving a single post by ID."); + +export type GetPostInput = z.infer; + +// --------------------------------------------------------------------------- +// circle_list_members +// --------------------------------------------------------------------------- + +export const ListMembersInputSchema = z + .object({ + page: pageParam, + per_page: perPageParam, + status: z + .enum(["all", "inactive"]) + .optional() + .describe( + 'Member status filter. Omit for active members only, "all" for everyone, "inactive" for inactive only.' + ), + }) + .strict() + .describe("Parameters for listing community members."); + +export type ListMembersInput = z.infer; + +// --------------------------------------------------------------------------- +// circle_search +// --------------------------------------------------------------------------- + +export const SearchInputSchema = z + .object({ + query: z + .string() + .min(1) + .describe("Search query string (required). Searches across the community."), + page: pageParam, + per_page: perPageParam, + type: z + .enum([ + "general", + "members", + "posts", + "comments", + "spaces", + "lessons", + "events", + "entity_list", + "mentions", + ]) + .optional() + .describe( + 'Filter results by type. Defaults to "general" which returns all types.' + ), + }) + .strict() + .describe( + "Parameters for searching across the community. Returns lightweight summary objects." + ); + +export type SearchInput = z.infer; + +// =========================================================================== +// v0.2.0 Schemas +// =========================================================================== + +// --------------------------------------------------------------------------- +// circle_list_comments +// --------------------------------------------------------------------------- + +export const ListCommentsInputSchema = z + .object({ + page: pageParam, + per_page: perPageParam, + post_id: z + .number() + .int() + .positive() + .optional() + .describe("Filter comments to a specific post by its numeric ID."), + space_id: z + .number() + .int() + .positive() + .optional() + .describe("Filter comments to a specific space by its numeric ID."), + }) + .strict() + .describe( + "Parameters for listing comments. Filter by post_id to get comments on a specific post, " + + "or by space_id to get comments across all posts in a space." + ); + +export type ListCommentsInput = z.infer; + +// --------------------------------------------------------------------------- +// circle_get_comment +// --------------------------------------------------------------------------- + +export const GetCommentInputSchema = z + .object({ + comment_id: z + .number() + .int() + .positive() + .describe("The numeric ID of the comment to retrieve."), + }) + .strict() + .describe("Parameters for retrieving a single comment by ID."); + +export type GetCommentInput = z.infer; + +// --------------------------------------------------------------------------- +// circle_list_topics +// --------------------------------------------------------------------------- + +export const ListTopicsInputSchema = z + .object({ + page: pageParam, + per_page: perPageParam, + }) + .strict() + .describe("Parameters for listing all topics (tags/categories) in the community."); + +export type ListTopicsInput = z.infer; + +// --------------------------------------------------------------------------- +// circle_get_community +// --------------------------------------------------------------------------- + +export const GetCommunityInputSchema = z + .object({}) + .strict() + .describe( + "No parameters needed. Returns community-level metadata including name, URL, and settings." + ); + +export type GetCommunityInput = z.infer; + +// --------------------------------------------------------------------------- +// circle_list_space_groups +// --------------------------------------------------------------------------- + +export const ListSpaceGroupsInputSchema = z + .object({ + page: pageParam, + per_page: perPageParam, + }) + .strict() + .describe( + "Parameters for listing space groups. Space groups organize spaces into " + + "navigational categories." + ); + +export type ListSpaceGroupsInput = z.infer; + +// --------------------------------------------------------------------------- +// circle_detect_unanswered_posts (derived intelligence) +// --------------------------------------------------------------------------- + +export const DetectUnansweredPostsInputSchema = z + .object({ + space_id: z + .number() + .int() + .positive() + .optional() + .describe("Optional: limit detection to a specific space."), + per_page: z + .number() + .int() + .positive() + .max(100) + .optional() + .describe("Number of posts to scan per page (1-100). Defaults to 20."), + page: pageParam, + }) + .strict() + .describe( + "Detect posts with zero comments — potential unanswered questions or unengaged content. " + + "Scans published posts and filters to those with comments_count === 0. " + + "Computation: fetches posts from Circle API, then filters client-side." + ); + +export type DetectUnansweredPostsInput = z.infer; + +// --------------------------------------------------------------------------- +// circle_community_health (derived intelligence) +// --------------------------------------------------------------------------- + +export const CommunityHealthInputSchema = z + .object({}) + .strict() + .describe( + "Generate a community health snapshot by aggregating data from multiple Circle API endpoints. " + + "Computation: fetches community info, space count, post count, and member count in parallel, " + + "then assembles a summary. No opaque scores — all numbers are directly from Circle API." + ); + +export type CommunityHealthInput = z.infer; + +// =========================================================================== +// v0.3.0 Write Schemas +// =========================================================================== + +// --------------------------------------------------------------------------- +// circle_create_post +// --------------------------------------------------------------------------- + +export const CreatePostInputSchema = z + .object({ + space_id: z + .number() + .int() + .positive() + .describe("The numeric ID of the space to create the post in (required)."), + name: z + .string() + .min(1) + .describe("Post title (required). Must be non-empty."), + body: z + .string() + .min(1) + .describe( + "HTML body content (required). Circle accepts raw HTML strings. " + + "Example: '

Hello world

'" + ), + status: z + .enum(["published", "draft"]) + .optional() + .describe('Post status. Defaults to "published" if omitted.'), + is_comments_enabled: z + .boolean() + .optional() + .describe("Whether comments are enabled on this post. Defaults to true."), + is_liking_enabled: z + .boolean() + .optional() + .describe("Whether liking is enabled on this post. Defaults to true."), + skip_notifications: z + .boolean() + .optional() + .describe( + "Skip email notifications for this post. Useful for bulk imports or test content." + ), + }) + .strict() + .describe( + "Parameters for creating a new post in a space. " + + "Requires space_id, name, and body. Returns the full post object as created." + ); + +export type CreatePostInput = z.infer; + +// --------------------------------------------------------------------------- +// circle_update_post +// --------------------------------------------------------------------------- + +export const UpdatePostInputSchema = z + .object({ + post_id: z + .number() + .int() + .positive() + .describe("The numeric ID of the post to update (required)."), + name: z + .string() + .min(1) + .optional() + .describe("Updated post title."), + body: z + .string() + .min(1) + .optional() + .describe("Updated HTML body content."), + status: z + .enum(["published", "draft"]) + .optional() + .describe("Updated post status."), + is_comments_enabled: z + .boolean() + .optional() + .describe("Whether comments are enabled."), + is_liking_enabled: z + .boolean() + .optional() + .describe("Whether liking is enabled."), + }) + .strict() + .describe( + "Parameters for updating an existing post. " + + "Only provided fields are updated — omitted fields remain unchanged. " + + "At least one field besides post_id should be provided." + ); + +export type UpdatePostInput = z.infer; + +// --------------------------------------------------------------------------- +// circle_create_comment +// --------------------------------------------------------------------------- + +export const CreateCommentInputSchema = z + .object({ + post_id: z + .number() + .int() + .positive() + .describe("The numeric ID of the post to comment on (required)."), + body: z + .string() + .min(1) + .describe( + "HTML body content for the comment (required). " + + "Example: '

Great post!

'" + ), + }) + .strict() + .describe( + "Parameters for creating a comment on a post. " + + "Requires post_id and body. Returns the full comment object as created." + ); + +export type CreateCommentInput = z.infer; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..086b4c1 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,39 @@ +/** + * MCP Server factory for Circle.so. + * + * Creates and configures the McpServer instance with all v0.3 tools registered. + * Separated from index.ts to allow testing the server without starting transport. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { loadEnvConfig } from "./config/env.js"; +import type { ConfigOverrides } from "./config/env.js"; +import { CircleClient } from "./clients/circle-client.js"; +import { registerAllTools } from "./tools/index.js"; +import { VERSION } from "./version.js"; + +/** + * Create a fully configured Circle MCP server. + * + * @param overrides Optional per-request config (e.g. from Smithery query params). + * Falls back to process.env when omitted. + * @throws {Error} if required environment variables are missing + */ +export function createServer(overrides?: ConfigOverrides): McpServer { + // 1. Load and validate environment (overrides take precedence) + const config = loadEnvConfig(overrides); + + // 2. Create API client + const client = new CircleClient(config); + + // 3. Create MCP server + const server = new McpServer({ + name: "circle-so-mcp", + version: VERSION, + }); + + // 4. Register all tools + registerAllTools(server, client); + + return server; +} diff --git a/src/smithery.ts b/src/smithery.ts new file mode 100644 index 0000000..13572c5 --- /dev/null +++ b/src/smithery.ts @@ -0,0 +1,107 @@ +/** + * Smithery entry module for circle-mcp. + * + * Provides the ServerModule interface that Smithery expects: + * + * - default export: (context) => Server — main factory, called at runtime + * with user-provided config (circleApiToken, circleBaseUrl). + * + * - createSandboxServer: (context) => Server — sandbox factory, called + * during Smithery capability scanning with NO config and NO env. + * Registers all 16 tools against a no-op mock client so Smithery can + * discover the tool surface without making live API calls. + * + * This file does NOT replace index.ts (stdio) or http.ts (hosted HTTP). + * Those entrypoints remain unchanged. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerAllTools } from "./tools/index.js"; +import type { CircleClient } from "./clients/circle-client.js"; +import { VERSION } from "./version.js"; + +// --------------------------------------------------------------------------- +// Smithery config schema (mirrors smithery.yaml configSchema) +// --------------------------------------------------------------------------- + +interface SmitheryConfig { + circleApiToken: string; + circleBaseUrl?: string; +} + +// --------------------------------------------------------------------------- +// Mock CircleClient for sandbox scanning +// --------------------------------------------------------------------------- + +/** + * Creates a Proxy that satisfies the CircleClient interface at the type level + * but throws a clear error if any method is actually invoked. + * + * During Smithery capability scanning, tools are registered (so their names, + * descriptions, and input schemas are discoverable) but never invoked. + * This mock ensures no live API calls can occur during scanning. + */ +function createSandboxClient(): CircleClient { + return new Proxy({} as CircleClient, { + get(_target, prop) { + // Return a function that throws if invoked + return (..._args: unknown[]) => { + throw new Error( + `[sandbox] CircleClient.${String(prop)}() called during capability scan. ` + + `This is a sandbox-only mock — no live API calls are permitted.`, + ); + }; + }, + }); +} + +// --------------------------------------------------------------------------- +// Smithery default export: runtime server factory +// --------------------------------------------------------------------------- + +/** + * Called by Smithery at runtime with user-provided configuration. + * Sets env vars and delegates to the standard createServer() flow. + */ +async function createServerForSmithery(context: { + config: SmitheryConfig; + env?: Record; +}) { + // Inject config into process.env so loadEnvConfig() finds it + process.env.CIRCLE_API_TOKEN = context.config.circleApiToken; + if (context.config.circleBaseUrl) { + process.env.CIRCLE_BASE_URL = context.config.circleBaseUrl; + } + + // Import createServer dynamically to ensure env is set before it runs + const { createServer } = await import("./server.js"); + const mcpServer = createServer(); + return mcpServer.server; +} + +export default createServerForSmithery; + +// --------------------------------------------------------------------------- +// Smithery named export: sandbox server factory +// --------------------------------------------------------------------------- + +/** + * Called by Smithery during capability scanning. + * + * Creates a fully registered McpServer (all 16 tools) backed by a no-op + * mock client. Smithery connects to this server, sends initialize + tools/list, + * and discovers the tool surface without requiring real credentials. + */ +export async function createSandboxServer(_context?: { + session?: { id: string }; +}) { + const server = new McpServer({ + name: "circle-so-mcp", + version: VERSION, + }); + + const sandboxClient = createSandboxClient(); + registerAllTools(server, sandboxClient); + + return server.server; +} diff --git a/src/tools/community-health.ts b/src/tools/community-health.ts new file mode 100644 index 0000000..a4c2b56 --- /dev/null +++ b/src/tools/community-health.ts @@ -0,0 +1,89 @@ +/** + * MCP Tool: circle_community_health (Derived Intelligence) + * + * Generates a community health snapshot by aggregating data from multiple + * Circle API endpoints in parallel. + * + * Computation: fetches community info, spaces (page 1), posts (page 1), + * and members (page 1) concurrently, then assembles a summary dashboard. + * + * No opaque scores — all numbers are directly from Circle API response + * headers and pagination metadata. + * + * @since v0.2.0 + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import { CommunityHealthInputSchema } from "../schemas/inputs.js"; +import { normalizeApiError, formatErrorForMcp } from "../lib/errors.js"; +import { buildToolResponse } from "../lib/responses.js"; + +export function registerCommunityHealth(server: McpServer, client: CircleClient): void { + server.registerTool( + "circle_community_health", + { + title: "Community Health Snapshot", + description: + "Generate a community health snapshot by aggregating data from " + + "multiple Circle API endpoints. Fetches community info, space " + + "count, post count, and member count in parallel, then assembles " + + "a summary. All numbers come directly from Circle API pagination " + + "metadata — no opaque scores or fabricated analytics. No parameters " + + "required.", + inputSchema: CommunityHealthInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async () => { + try { + // Fetch all four data sources in parallel for efficiency + const [community, spaces, posts, members] = await Promise.all([ + client.getCommunity(), + client.listSpaces({ per_page: 1 }), + client.listPosts({ per_page: 1, status: "published" }), + client.listMembers({ per_page: 1 }), + ]); + + const result = { + computation: { + method: + "Parallel fetch of community, spaces, posts, and members endpoints. " + + "Counts are from Circle API pagination metadata (count field). " + + "No derived scores — raw numbers only.", + endpoints_called: [ + "GET /api/admin/v2/community", + "GET /api/admin/v2/spaces?per_page=1", + "GET /api/admin/v2/posts?per_page=1&status=published", + "GET /api/admin/v2/community_members?per_page=1", + ], + }, + community: { + id: community.id, + name: community.name, + slug: community.slug, + url: community.url, + logo_url: community.logo_url, + brand_color: community.brand_color, + }, + counts: { + total_spaces: spaces.count, + total_published_posts: posts.count, + total_members: members.count, + }, + snapshot_timestamp: new Date().toISOString(), + }; + + return buildToolResponse(result); + } catch (error: unknown) { + return formatErrorForMcp( + normalizeApiError(error, "Community health snapshot (multi-endpoint)") + ); + } + } + ); +} diff --git a/src/tools/create-comment.ts b/src/tools/create-comment.ts new file mode 100644 index 0000000..2b50989 --- /dev/null +++ b/src/tools/create-comment.ts @@ -0,0 +1,107 @@ +/** + * MCP Tool: circle_create_comment + * + * Creates a comment on a post. + * + * v0.3.0 — Endpoint exists but UNPROVEN (Prompt 12). + * Confidence: MEDIUM — POST /api/admin/v2/comments returns JSON 401 + * (not HTML 404), confirming the endpoint exists. However, the admin + * token consistently lacks comment-write permission. + * + * The handler includes permission-aware error handling: if the API + * returns a 401, a clear, operator-friendly message explains the + * known limitation rather than returning a cryptic error. + * + * MCP annotations per Anthropic Directory Policy: + * - readOnlyHint: false (this tool mutates data) + * - destructiveHint: true (writes/modifies data per Anthropic annotation guidance) + * - idempotentHint: false (each call creates a new comment) + * - openWorldHint: true + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import { CreateCommentInputSchema } from "../schemas/inputs.js"; +import { + CircleApiError, + normalizeApiError, + formatErrorForMcp, +} from "../lib/errors.js"; +import { buildMutationResponse } from "../lib/responses.js"; + +/** + * Build an operator-friendly error response for the known comment + * permission limitation. This is a dedicated handler because the 401 + * on comment creation is a known, documented constraint — not a + * misconfiguration by the operator. + */ +function buildCommentPermissionError(postId: number): { + content: Array<{ type: "text"; text: string }>; + isError: true; +} { + const message = + `Comment creation failed for POST /api/admin/v2/comments (401). ` + + `The Circle Admin API token does not have permission to create ` + + `comments via the API. This is a known limitation of the Circle ` + + `Admin API v2 — the endpoint exists but consistently returns ` + + `"You cannot perform this action" for admin tokens.\n\n` + + `Target post_id: ${postId}\n\n` + + `Workaround: Create comments directly in the Circle web interface. ` + + `The circle_list_comments and circle_get_comment read tools work ` + + `normally with this token.`; + + return { + content: [{ type: "text" as const, text: message }], + isError: true, + }; +} + +export function registerCreateComment( + server: McpServer, + client: CircleClient +): void { + server.registerTool( + "circle_create_comment", + { + title: "Create Circle Comment", + description: + "Create a comment on a Circle post. Requires post_id (use " + + "circle_list_posts or circle_get_post to find IDs) and HTML " + + "body content. Note: The Circle Admin API may restrict comment " + + "creation depending on your API token permissions. If you receive " + + "a permission error, create comments directly in the Circle " + + "web interface instead.", + inputSchema: CreateCommentInputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + async (params) => { + try { + const { post_id, ...request } = params; + const comment = await client.createComment(post_id, request); + + return buildMutationResponse( + "create", + comment, + "POST /api/admin/v2/comments" + ); + } catch (error: unknown) { + // Permission-aware error handling for the known 401 limitation + const normalized = normalizeApiError( + error, + "POST /api/admin/v2/comments" + ); + + if (normalized.statusCode === 401 || normalized.statusCode === 403) { + return buildCommentPermissionError(params.post_id); + } + + return formatErrorForMcp(normalized); + } + } + ); +} diff --git a/src/tools/create-post.ts b/src/tools/create-post.ts new file mode 100644 index 0000000..88380c8 --- /dev/null +++ b/src/tools/create-post.ts @@ -0,0 +1,61 @@ +/** + * MCP Tool: circle_create_post + * + * Creates a new post in a specified space. + * + * v0.3.0 — Live-proven endpoint (Prompt 12). + * Confidence: HIGH — POST /api/admin/v2/posts returns 200 with flat payload. + * + * MCP annotations per Anthropic Directory Policy: + * - readOnlyHint: false (this tool mutates data) + * - destructiveHint: true (writes/modifies data per Anthropic annotation guidance) + * - idempotentHint: false (each call creates a new post) + * - openWorldHint: true + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import { CreatePostInputSchema } from "../schemas/inputs.js"; +import { normalizeApiError, formatErrorForMcp } from "../lib/errors.js"; +import { buildMutationResponse } from "../lib/responses.js"; + +export function registerCreatePost( + server: McpServer, + client: CircleClient +): void { + server.registerTool( + "circle_create_post", + { + title: "Create Circle Post", + description: + "Create a new post in a Circle space. Requires space_id (use " + + "circle_list_spaces to find IDs), a title (name), and HTML body " + + "content. Posts default to 'published' status. Set status to 'draft' " + + "to save without publishing. Use skip_notifications: true for test " + + "or bulk content. Returns the full created post object.", + inputSchema: CreatePostInputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + async (params) => { + try { + const { space_id, ...request } = params; + const post = await client.createPost(space_id, request); + + return buildMutationResponse( + "create", + post, + "POST /api/admin/v2/posts" + ); + } catch (error: unknown) { + return formatErrorForMcp( + normalizeApiError(error, "POST /api/admin/v2/posts") + ); + } + } + ); +} diff --git a/src/tools/detect-unanswered-posts.ts b/src/tools/detect-unanswered-posts.ts new file mode 100644 index 0000000..ce51670 --- /dev/null +++ b/src/tools/detect-unanswered-posts.ts @@ -0,0 +1,84 @@ +/** + * MCP Tool: circle_detect_unanswered_posts (Derived Intelligence) + * + * Scans published posts and returns those with zero comments. + * Useful for identifying unanswered questions or unengaged content. + * + * Computation: fetches posts from Circle API → filters client-side + * where comments_count === 0. No opaque scoring — all numbers come + * directly from the Circle API. + * + * @since v0.2.0 + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import type { CirclePost } from "../types/circle.js"; +import { DetectUnansweredPostsInputSchema } from "../schemas/inputs.js"; +import { normalizeApiError, formatErrorForMcp } from "../lib/errors.js"; +import { buildToolResponse } from "../lib/responses.js"; + +export function registerDetectUnansweredPosts(server: McpServer, client: CircleClient): void { + server.registerTool( + "circle_detect_unanswered_posts", + { + title: "Detect Unanswered Posts", + description: + "Detect posts with zero comments — potential unanswered questions or " + + "unengaged content. Fetches published posts from the Circle API and " + + "filters client-side to those with comments_count === 0. Optionally " + + "limit to a specific space. All numbers come directly from the " + + "Circle API — no opaque scores or fabricated analytics.", + inputSchema: DetectUnansweredPostsInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const response = await client.listPosts({ + page: params.page, + per_page: params.per_page ?? 20, + space_id: params.space_id, + status: "published", + sort: "latest", + }); + + const allPosts = response.records; + const unanswered = allPosts.filter( + (post: CirclePost) => (post.comments_count ?? 0) === 0 + ); + + const result = { + computation: { + method: "Filter posts where comments_count === 0", + posts_scanned: allPosts.length, + unanswered_count: unanswered.length, + page: response.page, + has_next_page: response.has_next_page, + }, + unanswered_posts: unanswered.map((post: CirclePost) => ({ + id: post.id, + name: post.name, + slug: post.slug, + space_id: post.space_id, + user_name: post.user_name, + comments_count: post.comments_count ?? 0, + likes_count: post.likes_count, + created_at: post.created_at, + url: post.url, + })), + }; + + return buildToolResponse(result); + } catch (error: unknown) { + return formatErrorForMcp( + normalizeApiError(error, "GET /api/admin/v2/posts (unanswered detection)") + ); + } + } + ); +} diff --git a/src/tools/get-comment.ts b/src/tools/get-comment.ts new file mode 100644 index 0000000..849723a --- /dev/null +++ b/src/tools/get-comment.ts @@ -0,0 +1,43 @@ +/** + * MCP Tool: circle_get_comment + * + * Retrieves a single comment by its numeric ID. + * + * @since v0.2.0 + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import { GetCommentInputSchema } from "../schemas/inputs.js"; +import { normalizeApiError, formatErrorForMcp } from "../lib/errors.js"; +import { buildToolResponse } from "../lib/responses.js"; + +export function registerGetComment(server: McpServer, client: CircleClient): void { + server.registerTool( + "circle_get_comment", + { + title: "Get Circle Comment", + description: + "Retrieve a single Circle comment by its numeric ID. Returns full " + + "comment details including body text, author info, and engagement " + + "metrics. Use circle_list_comments to discover comment IDs.", + inputSchema: GetCommentInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const comment = await client.getComment(params.comment_id); + return buildToolResponse(comment); + } catch (error: unknown) { + return formatErrorForMcp( + normalizeApiError(error, `GET /api/admin/v2/comments/${params.comment_id}`) + ); + } + } + ); +} diff --git a/src/tools/get-community.ts b/src/tools/get-community.ts new file mode 100644 index 0000000..aa16556 --- /dev/null +++ b/src/tools/get-community.ts @@ -0,0 +1,44 @@ +/** + * MCP Tool: circle_get_community + * + * Retrieves community-level metadata including name, URL, and settings. + * + * @since v0.2.0 + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import { GetCommunityInputSchema } from "../schemas/inputs.js"; +import { normalizeApiError, formatErrorForMcp } from "../lib/errors.js"; +import { buildToolResponse } from "../lib/responses.js"; + +export function registerGetCommunity(server: McpServer, client: CircleClient): void { + server.registerTool( + "circle_get_community", + { + title: "Get Circle Community", + description: + "Retrieve community-level metadata for the connected Circle community. " + + "Returns the community name, URL, slug, branding info, locale, and " + + "settings. No parameters required — uses the configured API token to " + + "identify the community.", + inputSchema: GetCommunityInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async () => { + try { + const community = await client.getCommunity(); + return buildToolResponse(community); + } catch (error: unknown) { + return formatErrorForMcp( + normalizeApiError(error, "GET /api/admin/v2/community") + ); + } + } + ); +} diff --git a/src/tools/get-post.ts b/src/tools/get-post.ts new file mode 100644 index 0000000..fbbf419 --- /dev/null +++ b/src/tools/get-post.ts @@ -0,0 +1,42 @@ +/** + * MCP Tool: circle_get_post + * + * Retrieves a single post by its numeric ID. + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import { GetPostInputSchema } from "../schemas/inputs.js"; +import { normalizeApiError, formatErrorForMcp } from "../lib/errors.js"; +import { buildToolResponse } from "../lib/responses.js"; + +export function registerGetPost(server: McpServer, client: CircleClient): void { + server.registerTool( + "circle_get_post", + { + title: "Get Circle Post", + description: + "Retrieve a single Circle post by its numeric ID. Returns full " + + "post details including title, HTML body, structured TipTap body, " + + "author info, and engagement metrics. Use circle_list_posts to " + + "discover post IDs.", + inputSchema: GetPostInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const post = await client.getPost(params.post_id); + return buildToolResponse(post); + } catch (error: unknown) { + return formatErrorForMcp( + normalizeApiError(error, `GET /api/admin/v2/posts/${params.post_id}`) + ); + } + } + ); +} diff --git a/src/tools/get-space.ts b/src/tools/get-space.ts new file mode 100644 index 0000000..f0592b4 --- /dev/null +++ b/src/tools/get-space.ts @@ -0,0 +1,41 @@ +/** + * MCP Tool: circle_get_space + * + * Retrieves a single space by its numeric ID. + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import { GetSpaceInputSchema } from "../schemas/inputs.js"; +import { normalizeApiError, formatErrorForMcp } from "../lib/errors.js"; +import { buildToolResponse } from "../lib/responses.js"; + +export function registerGetSpace(server: McpServer, client: CircleClient): void { + server.registerTool( + "circle_get_space", + { + title: "Get Circle Space", + description: + "Retrieve a single Circle space by its numeric ID. Returns full " + + "space details including name, type, URL, visibility settings, " + + "and configuration. Use circle_list_spaces to discover space IDs.", + inputSchema: GetSpaceInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const space = await client.getSpace(params.space_id); + return buildToolResponse(space); + } catch (error: unknown) { + return formatErrorForMcp( + normalizeApiError(error, `GET /api/admin/v2/spaces/${params.space_id}`) + ); + } + } + ); +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..cfc726a --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,89 @@ +/** + * Tool registration index. + * + * Registers all 16 Circle MCP tools on the provided server instance. + * + * v0.1.0: 6 tools (spaces, posts, members, search) + * v0.2.0: 13 tools (+comments, topics, community, space groups, derived intelligence) + * v0.3.0: 16 tools (+createPost, updatePost, createComment) + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; + +// v0.1.0 tools +import { registerListSpaces } from "./list-spaces.js"; +import { registerGetSpace } from "./get-space.js"; +import { registerListPosts } from "./list-posts.js"; +import { registerGetPost } from "./get-post.js"; +import { registerListMembers } from "./list-members.js"; +import { registerSearch } from "./search.js"; + +// v0.2.0 read tools +import { registerListComments } from "./list-comments.js"; +import { registerGetComment } from "./get-comment.js"; +import { registerListTopics } from "./list-topics.js"; +import { registerGetCommunity } from "./get-community.js"; +import { registerListSpaceGroups } from "./list-space-groups.js"; + +// v0.2.0 derived intelligence tools +import { registerDetectUnansweredPosts } from "./detect-unanswered-posts.js"; +import { registerCommunityHealth } from "./community-health.js"; + +// v0.3.0 write tools +import { registerCreatePost } from "./create-post.js"; +import { registerUpdatePost } from "./update-post.js"; +import { registerCreateComment } from "./create-comment.js"; + +/** + * Register all MCP tools on the server. + * + * Tool names are locked and must not be changed: + * + * v0.1.0: + * - circle_list_spaces + * - circle_get_space + * - circle_list_posts + * - circle_get_post + * - circle_list_members + * - circle_search + * + * v0.2.0: + * - circle_list_comments + * - circle_get_comment + * - circle_list_topics + * - circle_get_community + * - circle_list_space_groups + * - circle_detect_unanswered_posts + * - circle_community_health + * + * v0.3.0: + * - circle_create_post + * - circle_update_post + * - circle_create_comment + */ +export function registerAllTools(server: McpServer, client: CircleClient): void { + // v0.1.0 tools + registerListSpaces(server, client); + registerGetSpace(server, client); + registerListPosts(server, client); + registerGetPost(server, client); + registerListMembers(server, client); + registerSearch(server, client); + + // v0.2.0 read tools + registerListComments(server, client); + registerGetComment(server, client); + registerListTopics(server, client); + registerGetCommunity(server, client); + registerListSpaceGroups(server, client); + + // v0.2.0 derived intelligence tools + registerDetectUnansweredPosts(server, client); + registerCommunityHealth(server, client); + + // v0.3.0 write tools + registerCreatePost(server, client); + registerUpdatePost(server, client); + registerCreateComment(server, client); +} diff --git a/src/tools/list-comments.ts b/src/tools/list-comments.ts new file mode 100644 index 0000000..8070bcd --- /dev/null +++ b/src/tools/list-comments.ts @@ -0,0 +1,56 @@ +/** + * MCP Tool: circle_list_comments + * + * Lists comments in the Circle community with optional filtering by post or space. + * + * @since v0.2.0 + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import { ListCommentsInputSchema } from "../schemas/inputs.js"; +import { extractPaginationSummary } from "../lib/pagination.js"; +import { normalizeApiError, formatErrorForMcp } from "../lib/errors.js"; +import { buildToolResponse } from "../lib/responses.js"; + +export function registerListComments(server: McpServer, client: CircleClient): void { + server.registerTool( + "circle_list_comments", + { + title: "List Circle Comments", + description: + "List comments in the Circle community. Filter by post_id to get " + + "comments on a specific post, or by space_id to get comments across " + + "all posts in a space. Returns paginated results with comment text, " + + "author info, and engagement metrics.", + inputSchema: ListCommentsInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const response = await client.listComments({ + page: params.page, + per_page: params.per_page, + post_id: params.post_id, + space_id: params.space_id, + }); + + const result = { + pagination: extractPaginationSummary(response), + comments: response.records, + }; + + return buildToolResponse(result); + } catch (error: unknown) { + return formatErrorForMcp( + normalizeApiError(error, "GET /api/admin/v2/comments") + ); + } + } + ); +} diff --git a/src/tools/list-members.ts b/src/tools/list-members.ts new file mode 100644 index 0000000..a43416a --- /dev/null +++ b/src/tools/list-members.ts @@ -0,0 +1,56 @@ +/** + * MCP Tool: circle_list_members + * + * Lists community members with pagination and status filtering. + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import { ListMembersInputSchema } from "../schemas/inputs.js"; +import { extractPaginationSummary } from "../lib/pagination.js"; +import { normalizeApiError, formatErrorForMcp } from "../lib/errors.js"; +import { buildToolResponse } from "../lib/responses.js"; + +export function registerListMembers( + server: McpServer, + client: CircleClient +): void { + server.registerTool( + "circle_list_members", + { + title: "List Circle Members", + description: + "List community members in Circle. Returns paginated results " + + "with member details including name, email, profile URL, " + + "activity stats, and profile fields. Filter by status to " + + 'include inactive members (default returns active only, use "all" for everyone).', + inputSchema: ListMembersInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const response = await client.listMembers({ + page: params.page, + per_page: params.per_page, + status: params.status, + }); + + const result = { + pagination: extractPaginationSummary(response), + members: response.records, + }; + + return buildToolResponse(result); + } catch (error: unknown) { + return formatErrorForMcp( + normalizeApiError(error, "GET /api/admin/v2/community_members") + ); + } + } + ); +} diff --git a/src/tools/list-posts.ts b/src/tools/list-posts.ts new file mode 100644 index 0000000..428432f --- /dev/null +++ b/src/tools/list-posts.ts @@ -0,0 +1,59 @@ +/** + * MCP Tool: circle_list_posts + * + * Lists posts in the Circle community with pagination, filtering, and sorting. + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import { ListPostsInputSchema } from "../schemas/inputs.js"; +import { extractPaginationSummary } from "../lib/pagination.js"; +import { normalizeApiError, formatErrorForMcp } from "../lib/errors.js"; +import { buildToolResponse } from "../lib/responses.js"; + +export function registerListPosts(server: McpServer, client: CircleClient): void { + server.registerTool( + "circle_list_posts", + { + title: "List Circle Posts", + description: + "List posts in the Circle community. Returns paginated results " + + "with full post details including title, content (HTML and TipTap), " + + "author info, and engagement metrics (likes_count, comments_count). " + + "Filter by space_id, space_group_id, status (draft/published/scheduled), " + + "or search_text. Use page/per_page for pagination and sort for ordering. " + + "For unanswered-post detection, use circle_detect_unanswered_posts instead.", + inputSchema: ListPostsInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const response = await client.listPosts({ + page: params.page, + per_page: params.per_page, + space_id: params.space_id, + space_group_id: params.space_group_id, + status: params.status, + search_text: params.search_text, + sort: params.sort, + }); + + const result = { + pagination: extractPaginationSummary(response), + posts: response.records, + }; + + return buildToolResponse(result); + } catch (error: unknown) { + return formatErrorForMcp( + normalizeApiError(error, "GET /api/admin/v2/posts") + ); + } + } + ); +} diff --git a/src/tools/list-space-groups.ts b/src/tools/list-space-groups.ts new file mode 100644 index 0000000..4916a45 --- /dev/null +++ b/src/tools/list-space-groups.ts @@ -0,0 +1,54 @@ +/** + * MCP Tool: circle_list_space_groups + * + * Lists space groups that organize spaces into navigational categories. + * + * @since v0.2.0 + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import { ListSpaceGroupsInputSchema } from "../schemas/inputs.js"; +import { extractPaginationSummary } from "../lib/pagination.js"; +import { normalizeApiError, formatErrorForMcp } from "../lib/errors.js"; +import { buildToolResponse } from "../lib/responses.js"; + +export function registerListSpaceGroups(server: McpServer, client: CircleClient): void { + server.registerTool( + "circle_list_space_groups", + { + title: "List Circle Space Groups", + description: + "List space groups in the Circle community. Space groups organize " + + "spaces into navigational categories (e.g., 'Getting Started', " + + "'Resources'). Returns paginated results with group name, position, " + + "and the IDs of spaces within each group.", + inputSchema: ListSpaceGroupsInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const response = await client.listSpaceGroups({ + page: params.page, + per_page: params.per_page, + }); + + const result = { + pagination: extractPaginationSummary(response), + space_groups: response.records, + }; + + return buildToolResponse(result); + } catch (error: unknown) { + return formatErrorForMcp( + normalizeApiError(error, "GET /api/admin/v2/space_groups") + ); + } + } + ); +} diff --git a/src/tools/list-spaces.ts b/src/tools/list-spaces.ts new file mode 100644 index 0000000..21e9e66 --- /dev/null +++ b/src/tools/list-spaces.ts @@ -0,0 +1,52 @@ +/** + * MCP Tool: circle_list_spaces + * + * Lists spaces in the Circle community with pagination and sorting. + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import { ListSpacesInputSchema } from "../schemas/inputs.js"; +import { extractPaginationSummary } from "../lib/pagination.js"; +import { normalizeApiError, formatErrorForMcp } from "../lib/errors.js"; +import { buildToolResponse } from "../lib/responses.js"; + +export function registerListSpaces(server: McpServer, client: CircleClient): void { + server.registerTool( + "circle_list_spaces", + { + title: "List Circle Spaces", + description: + "List spaces in the Circle community. Returns paginated results " + + "with space details including name, type, URL, and settings. " + + "Use page/per_page for pagination and sort for ordering.", + inputSchema: ListSpacesInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const response = await client.listSpaces({ + page: params.page, + per_page: params.per_page, + sort: params.sort, + }); + + const result = { + pagination: extractPaginationSummary(response), + spaces: response.records, + }; + + return buildToolResponse(result); + } catch (error: unknown) { + return formatErrorForMcp( + normalizeApiError(error, "GET /api/admin/v2/spaces") + ); + } + } + ); +} diff --git a/src/tools/list-topics.ts b/src/tools/list-topics.ts new file mode 100644 index 0000000..7494dc7 --- /dev/null +++ b/src/tools/list-topics.ts @@ -0,0 +1,53 @@ +/** + * MCP Tool: circle_list_topics + * + * Lists all topics (tags/categories) in the Circle community. + * + * @since v0.2.0 + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import { ListTopicsInputSchema } from "../schemas/inputs.js"; +import { extractPaginationSummary } from "../lib/pagination.js"; +import { normalizeApiError, formatErrorForMcp } from "../lib/errors.js"; +import { buildToolResponse } from "../lib/responses.js"; + +export function registerListTopics(server: McpServer, client: CircleClient): void { + server.registerTool( + "circle_list_topics", + { + title: "List Circle Topics", + description: + "List all topics (tags/categories) in the Circle community. Topics " + + "are used to categorize posts. Returns paginated results with topic " + + "name, slug, and post count.", + inputSchema: ListTopicsInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const response = await client.listTopics({ + page: params.page, + per_page: params.per_page, + }); + + const result = { + pagination: extractPaginationSummary(response), + topics: response.records, + }; + + return buildToolResponse(result); + } catch (error: unknown) { + return formatErrorForMcp( + normalizeApiError(error, "GET /api/admin/v2/topics") + ); + } + } + ); +} diff --git a/src/tools/search.ts b/src/tools/search.ts new file mode 100644 index 0000000..d03ae25 --- /dev/null +++ b/src/tools/search.ts @@ -0,0 +1,58 @@ +/** + * MCP Tool: circle_search + * + * Searches across the Circle community using advanced search. + * Returns lightweight summary objects — use detail endpoints for full data. + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import { SearchInputSchema } from "../schemas/inputs.js"; +import { extractPaginationSummary } from "../lib/pagination.js"; +import { normalizeApiError, formatErrorForMcp } from "../lib/errors.js"; +import { buildToolResponse } from "../lib/responses.js"; + +export function registerSearch(server: McpServer, client: CircleClient): void { + server.registerTool( + "circle_search", + { + title: "Search Circle Community", + description: + "Search across the Circle community. Returns lightweight summary " + + "objects with id, name, slug, and type. Results are NOT full " + + "resource objects — use circle_get_space, circle_get_post, or " + + "circle_get_comment for full details on individual results. " + + "Filter by type (posts, members, comments, spaces, lessons, " + + "events, entity_list, mentions) or omit for general search. " + + "The query parameter is required.", + inputSchema: SearchInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const response = await client.search({ + query: params.query, + page: params.page, + per_page: params.per_page, + type: params.type, + }); + + const result = { + pagination: extractPaginationSummary(response), + results: response.records, + }; + + return buildToolResponse(result); + } catch (error: unknown) { + return formatErrorForMcp( + normalizeApiError(error, "GET /api/admin/v2/advanced_search") + ); + } + } + ); +} diff --git a/src/tools/update-post.ts b/src/tools/update-post.ts new file mode 100644 index 0000000..9cbcad5 --- /dev/null +++ b/src/tools/update-post.ts @@ -0,0 +1,68 @@ +/** + * MCP Tool: circle_update_post + * + * Updates an existing post by its numeric ID. + * + * v0.3.0 — Live-proven endpoint (Prompt 12). + * Confidence: HIGH — PUT /api/admin/v2/posts/{id} returns 200 with flat payload. + * + * Known constraint: Published posts cannot be reverted to draft status. + * The API returns 400 "Status cannot be changed after the post is published". + * + * MCP annotations per Anthropic Directory Policy: + * - readOnlyHint: false (this tool mutates data) + * - destructiveHint: true (writes/modifies data per Anthropic annotation guidance) + * - idempotentHint: true (same update can be applied multiple times safely) + * - openWorldHint: true + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CircleClient } from "../clients/circle-client.js"; +import { UpdatePostInputSchema } from "../schemas/inputs.js"; +import { normalizeApiError, formatErrorForMcp } from "../lib/errors.js"; +import { buildMutationResponse } from "../lib/responses.js"; + +export function registerUpdatePost( + server: McpServer, + client: CircleClient +): void { + server.registerTool( + "circle_update_post", + { + title: "Update Circle Post", + description: + "Update an existing Circle post. Requires post_id (use " + + "circle_list_posts or circle_get_post to find IDs). Only provided " + + "fields are updated — omitted fields remain unchanged. " + + "Note: Published posts CANNOT be reverted to draft status; " + + "attempting to set status='draft' on a published post returns an " + + "error. Returns the full updated post object.", + inputSchema: UpdatePostInputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params) => { + try { + const { post_id, ...request } = params; + const post = await client.updatePost(post_id, request); + + return buildMutationResponse( + "update", + post, + `PUT /api/admin/v2/posts/${post_id}` + ); + } catch (error: unknown) { + return formatErrorForMcp( + normalizeApiError( + error, + `PUT /api/admin/v2/posts/${params.post_id}` + ) + ); + } + } + ); +} diff --git a/src/types/circle.ts b/src/types/circle.ts new file mode 100644 index 0000000..e2a8ac2 --- /dev/null +++ b/src/types/circle.ts @@ -0,0 +1,437 @@ +/** + * Circle.so domain types derived from live API responses. + * + * These types reflect the ACTUAL shapes returned by the Circle Admin API v2, + * validated against live samples captured during Prompt 2/6 contract discovery. + * + * Fields marked optional were observed as null in some responses. + * Fields typed as `unknown` are present but not yet fully explored. + * + * @see artifacts/samples/ for the raw JSON these types were derived from + * @see docs/contracts/endpoint-inventory.md for field-level documentation + */ + +// --------------------------------------------------------------------------- +// Space +// --------------------------------------------------------------------------- + +export interface CircleSpaceGroup { + id: number; + name: string; +} + +export interface CircleSpace { + id: number; + name: string; + slug: string; + community_id: number; + space_type: string; + is_private: boolean; + is_hidden: boolean; + is_hidden_from_non_members: boolean; + is_post_disabled: boolean; + url: string; + emoji: string; + display_view: string; + cover_image_url: string | null; + cover_image_visible: boolean; + cover_image_display_style: string; + custom_emoji_url: string | null; + custom_emoji_dark_url: string | null; + thumbnail_image_url: string | null; + host: string | null; + post_ids: number[]; + space_group: CircleSpaceGroup; + topics: unknown[]; + event_auto_rsvp_enabled: boolean; + hide_post_settings: boolean; + default_sort: string; + default_comment_sort: string; + default_member_sort: string; + default_tab: string; + show_tab_bar: boolean; + show_next_event: boolean; + hide_from_sidebar: boolean; + hide_right_sidebar: boolean; + visible_tabs: Record; + default_new_post_notification_setting: string; + default_new_comment_on_my_post_notification_setting: string; + default_new_comment_notification_setting: string; + default_new_reaction_on_my_post_notification_setting: string; + default_new_reaction_on_my_comment_notification_setting: string; + default_new_event_notification_setting: string; + locked_button_url: string | null; + locked_button_label: string | null; + locked_page_heading: string | null; + locked_page_description: string | null; + hide_sorting: boolean; + require_topic_selection: boolean; + prevent_members_from_adding_others: boolean; + hide_from_featured_areas: boolean; + disable_member_post_covers: boolean; + hide_members_count: boolean | null; + pinned_posts_label: string | null; + show_lock_icon_for_non_members: boolean; + lock_screen_blocks: unknown | null; +} + +// --------------------------------------------------------------------------- +// Post +// --------------------------------------------------------------------------- + +/** Rendered HTML body attached to a post. */ +export interface CirclePostBody { + id: number; + name: string; + /** Rendered HTML content. */ + body: string; + record_type: string; + record_id: number; +} + +/** A single node in the TipTap document tree. */ +export interface TipTapNode { + type: string; + content?: TipTapNode[]; + text?: string; + attrs?: Record; + marks?: Array<{ type: string; attrs?: Record }>; +} + +/** TipTap structured body — the richest representation of post content. */ +export interface TipTapBody { + body: { + type: "doc"; + content: TipTapNode[]; + }; + circle_ios_fallback_text: string; + attachments: unknown[]; + inline_attachments: unknown[]; + sgids_to_object_map: Record; + format: string; + community_members: unknown[]; + entities: unknown[]; + group_mentions: unknown[]; + polls: unknown[]; +} + +export interface CirclePost { + id: number; + name: string; + slug: string; + status: string; + url: string; + space_id: number; + space_name: string; + space_slug: string; + community_id: number; + user_id: number; + user_email: string; + user_name: string; + user_avatar_url: string | null; + comments_count: number; + likes_count: number; + hide_meta_info: boolean; + is_comments_enabled: boolean; + is_liking_enabled: boolean; + is_comments_closed: boolean; + published_at: string | null; + created_at: string; + updated_at: string; + flagged_for_approval_at: string | null; + body: CirclePostBody; + tiptap_body: TipTapBody; + cover_image_url: string | null; + cover_image: string | null; + cardview_thumbnail_url: string | null; + cardview_thumbnail: string | null; + custom_html: string | null; + member_posts_count: number; + member_comments_count: number; + member_likes_count: number; + topics: unknown[]; +} + +// --------------------------------------------------------------------------- +// Member +// --------------------------------------------------------------------------- + +export interface CircleProfileField { + id: number; + label: string; + field_type: string; + required: boolean; + value: string | null; + community_id: number; + alt_label: string | null; + form_field_id: number | null; + hidden: boolean; +} + +export interface CircleFlattenedProfileFields { + headline: string | null; + bio: string | null; + location: string | null; + website: string | null; + twitter_url: string | null; +} + +export interface CircleMember { + id: number; + first_name: string; + last_name: string; + name: string; + email: string; + headline: string | null; + avatar_url: string | null; + profile_url: string; + public_uid: string; + user_id: number; + community_id: number; + active: boolean; + accepted_invitation: string; + created_at: string; + updated_at: string; + last_seen_at: string | null; + profile_confirmed_at: string | null; + sso_provider_user_id: string | null; + posts_count: number; + comments_count: number; + member_tags: unknown[]; + profile_fields: CircleProfileField[]; + flattened_profile_fields: CircleFlattenedProfileFields; + gamification_stats: Record | null; +} + +// --------------------------------------------------------------------------- +// Comment (v0.2.0) +// --------------------------------------------------------------------------- + +/** + * A comment on a post. + * + * Derived from Swagger spec and Circle API patterns. + * Fields marked optional may be absent depending on post type or API version. + * + * Note: Comments endpoint returns 0 records in test community — types are + * derived from Swagger spec and common Circle patterns. May require refinement + * once real comment data is available. + */ +export interface CircleComment { + id: number; + body: CirclePostBody; + tiptap_body: TipTapBody | null; + user_id: number; + user_name: string; + user_email: string; + user_avatar_url: string | null; + post_id: number; + space_id: number; + community_id: number; + likes_count: number; + created_at: string; + updated_at: string; + /** Whether the comment author is an admin. */ + is_admin?: boolean; + /** Whether the comment author is a moderator. */ + is_moderator?: boolean; + /** Number of replies to this comment. */ + replies_count?: number; + /** URL to the comment in the community. */ + url?: string; +} + +// --------------------------------------------------------------------------- +// Topic (v0.2.0) +// --------------------------------------------------------------------------- + +/** + * A topic (tag/category) in the community. + * + * Topics can be assigned to posts for categorization. + * + * Note: Topics endpoint returns 0 records in test community — types are + * derived from Swagger spec. May require refinement once real topic data + * is available. + */ +export interface CircleTopic { + id: number; + name: string; + slug: string; + community_id: number; + /** Number of posts tagged with this topic. */ + posts_count?: number; + created_at?: string; + updated_at?: string; +} + +// --------------------------------------------------------------------------- +// Community (v0.2.0) +// --------------------------------------------------------------------------- + +/** + * Community-level metadata returned by GET /community. + * + * Contains the name, settings, and high-level info about the community. + * Fields verified against live API response (community 495139). + */ +export interface CircleCommunity { + id: number; + name: string; + slug: string; + url: string; + logo_url: string | null; + favicon_url: string | null; + brand_color: string | null; + /** Whether the community requires members to confirm their email. */ + email_confirmation_required?: boolean; + /** Whether the community is private (invite-only). */ + is_private?: boolean; + /** Locale setting (e.g. "en"). */ + locale?: string; + created_at?: string; + updated_at?: string; + /** Community-level settings — shape varies by Circle plan tier. */ + settings?: Record; +} + +// --------------------------------------------------------------------------- +// Space Group Detail (v0.2.0) +// --------------------------------------------------------------------------- + +/** + * Full space group object from GET /space_groups. + * + * Distinct from the embedded CircleSpaceGroup (id + name only) on CircleSpace. + * The detail version includes space IDs, ordering, and visibility. + * Verified against live API: 4 space groups returned for community 495139. + */ +export interface CircleSpaceGroupDetail { + id: number; + name: string; + community_id: number; + /** IDs of spaces within this group. */ + space_ids?: number[]; + /** Display position/order in navigation. */ + position?: number; + /** Whether the group is hidden from navigation. */ + is_hidden?: boolean; + created_at?: string; + updated_at?: string; +} + +// =========================================================================== +// v0.3.0 Write Request Types +// =========================================================================== + +// --------------------------------------------------------------------------- +// Post Write Requests (v0.3.0) +// --------------------------------------------------------------------------- + +/** + * Request body for creating a post via POST /api/admin/v2/posts. + * + * The Circle API v2 accepts a flat payload (no wrapping key). + * The space_id is included as a top-level field in the request body. + * + * Body format: HTML string. TipTap is server-generated for reads only — + * the write API accepts plain HTML in the `body` field. + * + * Note: Types derived from Swagger spec + live proving. + * Optional fields may have additional undocumented behaviors. + */ +export interface CreatePostRequest { + /** Post title (required). */ + name: string; + /** HTML body content (required). Circle accepts raw HTML strings. */ + body: string; + /** Post status: "published" or "draft" (default: "published"). */ + status?: "published" | "draft"; + /** Whether comments are enabled on this post. */ + is_comments_enabled?: boolean; + /** Whether liking is enabled on this post. */ + is_liking_enabled?: boolean; + /** Whether to skip email notifications for this post. */ + skip_notifications?: boolean; +} + +/** + * Request body for updating a post via PUT /api/admin/v2/posts/{id}. + * + * All fields are optional — only provided fields are updated. + * The Circle API v2 accepts a flat payload (no wrapping key). + */ +export interface UpdatePostRequest { + /** Updated post title. */ + name?: string; + /** Updated HTML body content. */ + body?: string; + /** Updated post status. */ + status?: "published" | "draft"; + /** Whether comments are enabled. */ + is_comments_enabled?: boolean; + /** Whether liking is enabled. */ + is_liking_enabled?: boolean; +} + +// --------------------------------------------------------------------------- +// Comment Write Requests (v0.3.0) +// --------------------------------------------------------------------------- + +/** + * Request body for creating a comment via POST /api/admin/v2/comments. + * + * The Circle API v2 accepts a flat payload with post_id as a top-level field. + * + * Note: This endpoint exists but returns 401 for admin tokens as of v0.3.1. + * See create-comment.ts for the permission-aware error handling. + */ +export interface CreateCommentRequest { + /** HTML body content for the comment (required). */ + body: string; +} + +// --------------------------------------------------------------------------- +// Mutation Response Wrapper (v0.3.0) +// --------------------------------------------------------------------------- + +/** + * Generic mutation response envelope. + * + * Wraps the API response with mutation metadata so the LLM + * knows what operation was performed and the result. + */ +export interface MutationResult { + /** The operation performed. */ + operation: "create" | "update"; + /** Whether the mutation succeeded. */ + success: boolean; + /** The resource returned by the API after the mutation. */ + data: T; + /** The API endpoint that was called. */ + endpoint: string; +} + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +/** + * Search result — lightweight summary object. + * + * The search endpoint returns these summary records, NOT full resource objects. + * Use the corresponding detail endpoint (get_space, get_post) for full data. + */ +export interface CircleSearchResult { + id: number; + name: string; + slug: string; + /** Discriminator: "space", "post", "member", "comment", "event", etc. */ + type: string; + sgid: string; + highlighted_name: string; + emoji: string; + custom_emoji_url: string | null; + custom_emoji_dark_url: string | null; + space_type: string | null; +} diff --git a/src/types/common.ts b/src/types/common.ts new file mode 100644 index 0000000..0b07b23 --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,129 @@ +/** + * Common types shared across the Circle MCP server. + */ + +/** + * Pagination envelope returned by all Circle list endpoints. + * Consistent across spaces, posts, members, and search. + * + * @see docs/contracts/endpoint-inventory.md §Pagination Contract + */ +export interface CirclePaginatedResponse { + /** Current page number (1-based). */ + page: number; + + /** Items per page. */ + per_page: number; + + /** Whether there are more pages after this one. */ + has_next_page: boolean; + + /** Total number of records matching the query. */ + count: number; + + /** Total number of pages. */ + page_count: number; + + /** The records on this page. */ + records: T[]; +} + +/** + * Standard pagination parameters accepted by all list endpoints. + */ +export interface PaginationParams { + page?: number; + per_page?: number; +} + +/** + * Maximum response text length (characters) to prevent oversized MCP responses. + * Circle API can return very large payloads — especially list endpoints with + * per_page=100 and posts containing full tiptap_body. + * + * 100,000 chars ≈ 25,000 tokens — a safe ceiling for most MCP clients. + */ +export const RESPONSE_TEXT_LIMIT = 100_000; + +/** + * The MCP SDK expects `structuredContent` to be `{ [x: string]: unknown }`. + * Our concrete interfaces don't satisfy this because they lack index signatures. + * This helper performs a runtime deep-clone via JSON round-trip, producing a + * plain object that TypeScript accepts as `Record`. + */ +export function toStructuredContent(data: unknown): Record { + return JSON.parse(JSON.stringify(data)) as Record; +} + +// --------------------------------------------------------------------------- +// Response truncation +// --------------------------------------------------------------------------- + +/** + * Metadata attached when a response is truncated. + * Always included in the response so the LLM knows data was clipped. + */ +export interface TruncationNotice { + truncated: true; + original_text_length: number; + truncated_text_length: number; + message: string; +} + +/** + * Result of preparing a tool response with truncation guard. + */ +export interface SafeTextResponse { + /** The (possibly truncated) text for `content[].text`. */ + text: string; + /** Non-null only if truncation occurred. */ + truncation: TruncationNotice | null; +} + +/** + * Prepare a JSON text response with truncation guard. + * + * Strategy: + * 1. If text fits within RESPONSE_TEXT_LIMIT, return it unchanged. + * 2. If text exceeds the limit, truncate and append a clear notice. + * + * For paginated list responses, the truncation preserves: + * - The pagination metadata (always at the top of the JSON) + * - As many complete records as fit within the limit + * - A truncation notice explaining what happened + * + * No silent data loss — the caller always knows if truncation occurred. + */ +export function prepareSafeText( + data: unknown, + limit: number = RESPONSE_TEXT_LIMIT +): SafeTextResponse { + const fullText = JSON.stringify(data, null, 2); + + if (fullText.length <= limit) { + return { text: fullText, truncation: null }; + } + + // Reserve space for the truncation notice footer + const notice: TruncationNotice = { + truncated: true, + original_text_length: fullText.length, + truncated_text_length: 0, // will be set below + message: + `Response truncated from ${fullText.length.toLocaleString()} to ~${limit.toLocaleString()} characters. ` + + `Use pagination (smaller per_page) or filter parameters to reduce result size.`, + }; + + const noticeJson = JSON.stringify(notice, null, 2); + const noticeFooter = `\n\n--- TRUNCATION NOTICE ---\n${noticeJson}`; + const truncateAt = limit - noticeFooter.length; + + // Find a clean break point — end of last complete line + const cleanBreak = fullText.lastIndexOf("\n", truncateAt); + const breakPoint = cleanBreak > 0 ? cleanBreak : truncateAt; + + const truncatedText = fullText.slice(0, breakPoint) + noticeFooter; + notice.truncated_text_length = truncatedText.length; + + return { text: truncatedText, truncation: notice }; +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..6b6d62a --- /dev/null +++ b/src/version.ts @@ -0,0 +1,13 @@ +/** + * Package version, read from package.json at runtime. + * + * Single source of truth — all entrypoints import from here + * instead of hardcoding version strings. + */ + +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const pkg = require("../package.json") as { version: string }; + +export const VERSION: string = pkg.version; diff --git a/test/live-v020.ts b/test/live-v020.ts new file mode 100644 index 0000000..fe1f153 --- /dev/null +++ b/test/live-v020.ts @@ -0,0 +1,153 @@ +/** + * Live validation for v0.2.0 tools against Circle API. + * + * Tests all 7 new v0.2.0 tools + 2 derived intelligence tools. + * Requires CIRCLE_API_TOKEN in environment. + */ + +import { CircleClient } from "../src/clients/circle-client.js"; +import { loadEnvConfig } from "../src/config/env.js"; + +const config = loadEnvConfig(); +const client = new CircleClient(config); + +interface Result { + status: "PASS" | "FAIL" | "SKIP"; + detail: string; +} + +async function validate(): Promise { + const results: Record = {}; + + // 1. circle_list_comments + try { + const r = await client.listComments({ per_page: 5 }); + results["circle_list_comments"] = { + status: "PASS", + detail: `count=${r.count}, records=${r.records.length}, page=${r.page}, has_next=${r.has_next_page}`, + }; + } catch (e: unknown) { + results["circle_list_comments"] = { status: "FAIL", detail: (e as Error).message }; + } + + // 2. circle_get_comment + try { + const list = await client.listComments({ per_page: 1 }); + if (list.records.length > 0) { + const id = (list.records[0] as { id: number }).id; + const c = await client.getComment(id); + results["circle_get_comment"] = { status: "PASS", detail: `id=${c.id}, has_body=${!!c.body}` }; + } else { + results["circle_get_comment"] = { + status: "SKIP", + detail: "No comments in community — cannot test single-get", + }; + } + } catch (e: unknown) { + results["circle_get_comment"] = { status: "FAIL", detail: (e as Error).message }; + } + + // 3. circle_list_topics + try { + const r = await client.listTopics({ per_page: 5 }); + results["circle_list_topics"] = { + status: "PASS", + detail: `count=${r.count}, records=${r.records.length}, page=${r.page}`, + }; + } catch (e: unknown) { + results["circle_list_topics"] = { status: "FAIL", detail: (e as Error).message }; + } + + // 4. circle_get_community + try { + const c = await client.getCommunity(); + results["circle_get_community"] = { + status: "PASS", + detail: `id=${c.id}, name="${c.name}", slug="${c.slug}", url="${c.url}"`, + }; + } catch (e: unknown) { + results["circle_get_community"] = { status: "FAIL", detail: (e as Error).message }; + } + + // 5. circle_list_space_groups + try { + const r = await client.listSpaceGroups({ per_page: 10 }); + results["circle_list_space_groups"] = { + status: "PASS", + detail: `count=${r.count}, records=${r.records.length}, page=${r.page}`, + }; + } catch (e: unknown) { + results["circle_list_space_groups"] = { status: "FAIL", detail: (e as Error).message }; + } + + // 6. circle_detect_unanswered_posts (derived — same logic as tool) + try { + const response = await client.listPosts({ + per_page: 20, + status: "published", + sort: "latest", + }); + const unanswered = response.records.filter( + (p: { comments_count?: number }) => (p.comments_count ?? 0) === 0 + ); + results["circle_detect_unanswered_posts"] = { + status: "PASS", + detail: `scanned=${response.records.length}, unanswered=${unanswered.length}, total_count=${response.count}`, + }; + } catch (e: unknown) { + results["circle_detect_unanswered_posts"] = { status: "FAIL", detail: (e as Error).message }; + } + + // 7. circle_community_health (derived — parallel fetch) + try { + const [community, spaces, posts, members] = await Promise.all([ + client.getCommunity(), + client.listSpaces({ per_page: 1 }), + client.listPosts({ per_page: 1, status: "published" }), + client.listMembers({ per_page: 1 }), + ]); + results["circle_community_health"] = { + status: "PASS", + detail: `community="${community.name}", spaces=${spaces.count}, posts=${posts.count}, members=${members.count}`, + }; + } catch (e: unknown) { + results["circle_community_health"] = { status: "FAIL", detail: (e as Error).message }; + } + + // Report + console.log("\n╔══════════════════════════════════════════════╗"); + console.log("║ v0.2.0 LIVE VALIDATION RESULTS ║"); + console.log("╠══════════════════════════════════════════════╣"); + for (const [tool, res] of Object.entries(results)) { + const icon = res.status === "PASS" ? "✅" : res.status === "SKIP" ? "⏭️ " : "❌"; + console.log(`║ ${icon} ${tool}`); + console.log(`║ ${res.status}: ${res.detail}`); + } + console.log("╚══════════════════════════════════════════════╝"); + + const passed = Object.values(results).filter((r) => r.status === "PASS").length; + const skipped = Object.values(results).filter((r) => r.status === "SKIP").length; + const failed = Object.values(results).filter((r) => r.status === "FAIL").length; + console.log(`\nResults: ${passed} passed, ${skipped} skipped, ${failed} failed`); + + // Write evidence + const evidence = { + timestamp: new Date().toISOString(), + results, + summary: { passed, skipped, failed }, + }; + const fs = await import("fs"); + fs.mkdirSync("test/evidence", { recursive: true }); + fs.writeFileSync( + "test/evidence/live-v020-results.json", + JSON.stringify(evidence, null, 2) + "\n" + ); + console.log("\nEvidence written to test/evidence/live-v020-results.json"); + + if (failed > 0) process.exit(1); +} + +validate().catch((e) => { + console.error("Fatal:", e); + process.exit(1); +}); diff --git a/test/prove-write-endpoints.ts b/test/prove-write-endpoints.ts new file mode 100644 index 0000000..bfd1487 --- /dev/null +++ b/test/prove-write-endpoints.ts @@ -0,0 +1,267 @@ +/** + * Live endpoint proving for v0.3.0 write operations. + * + * This script makes ACTUAL API calls to the Circle community to verify: + * 1. POST /api/admin/v2/posts — create a post (flat payload with space_id) + * 2. PUT /api/admin/v2/posts/{id} — update the post (flat payload) + * 3. POST /api/admin/v2/comments — create a comment (flat payload with post_id) + * + * LIVE PROVING FINDINGS (Prompt 12): + * - Circle API v2 uses FLAT endpoints (not nested resource paths) + * - Circle API v2 uses FLAT payloads (no resource key wrapper) + * - Create/Update post: ✅ PROVEN via live calls + * - Create comment: ⚠ EXPECTED_FAIL — admin token lacks comment-write permission + * The endpoint EXISTS (returns JSON 401, not HTML 404) but 401s consistently. + * + * SAFETY: + * - Creates disposable test content with [MCP_TEST] prefix + * - All content is clearly marked as automated test data + * - No existing community content is modified + * - Note: Published posts cannot be reverted to draft (API constraint) + * + * PREREQUISITES: + * - CIRCLE_API_TOKEN set in .env or environment + * - At least one space must exist in the community + * + * Run: npx tsx test/prove-write-endpoints.ts + * + * @see docs/evidence/PROMPT_12_V030_INFRA_AND_LIVE_PROVING.md + */ + +import { config } from "dotenv"; +config(); + +import { CircleClient } from "../dist/clients/circle-client.js"; +import { loadEnvConfig } from "../dist/config/env.js"; + +// --------------------------------------------------------------------------- +// Proving harness +// --------------------------------------------------------------------------- + +interface ProveResult { + endpoint: string; + method: string; + status: "PASS" | "FAIL" | "EXPECTED_FAIL"; + details: string; + responseSnippet?: Record; +} + +const results: ProveResult[] = []; + +function recordResult(result: ProveResult): void { + results.push(result); + const icon = + result.status === "PASS" + ? "✅" + : result.status === "EXPECTED_FAIL" + ? "⚠️" + : "❌"; + console.log(` ${icon} ${result.method} ${result.endpoint}: ${result.details}`); +} + +// --------------------------------------------------------------------------- +// Main proving sequence +// --------------------------------------------------------------------------- + +async function prove(): Promise { + console.log("\n╔════════════════════════════════════════════════╗"); + console.log("║ v0.3.0 Write Endpoint Live Proving ║"); + console.log("╚════════════════════════════════════════════════╝\n"); + + const envConfig = loadEnvConfig(); + const client = new CircleClient(envConfig); + + // Step 0: Find a space to test in + console.log("▸ Finding a test space..."); + const spacesResponse = await client.listSpaces({ per_page: 5 }); + const spaces = spacesResponse.records; + + if (spaces.length === 0) { + console.error("❌ No spaces found in the community. Cannot prove write endpoints."); + process.exit(1); + } + + const testSpace = spaces[0]; + console.log(` Using space: "${testSpace.name}" (id: ${testSpace.id})\n`); + + const timestamp = new Date().toISOString(); + + // ------------------------------------------------------------------------- + // PROVE 1: Create Post + // POST /api/admin/v2/posts (flat payload with space_id in body) + // ------------------------------------------------------------------------- + console.log("▸ Proving POST /api/admin/v2/posts (create post, flat payload)"); + + let createdPostId: number | null = null; + + try { + const post = await client.createPost(testSpace.id, { + name: `[MCP_TEST] Write Proving ${timestamp}`, + body: `

Automated test post created by Circle MCP Server v0.3.0 write proving.

Timestamp: ${timestamp}

`, + status: "draft", + skip_notifications: true, + is_comments_enabled: true, + is_liking_enabled: false, + }); + + createdPostId = post.id; + + recordResult({ + endpoint: "/api/admin/v2/posts", + method: "POST", + status: "PASS", + details: `Created post id=${post.id}, name="${post.name}", status="${post.status}"`, + responseSnippet: { + id: post.id, + name: post.name, + status: post.status, + space_id: post.space_id, + is_comments_enabled: post.is_comments_enabled, + is_liking_enabled: post.is_liking_enabled, + url: post.url, + }, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + recordResult({ + endpoint: "/api/admin/v2/posts", + method: "POST", + status: "FAIL", + details: message, + }); + } + + // ------------------------------------------------------------------------- + // PROVE 2: Update Post + // PUT /api/admin/v2/posts/{id} (flat payload, no wrapper) + // ------------------------------------------------------------------------- + if (createdPostId) { + console.log("\n▸ Proving PUT /api/admin/v2/posts/{id} (update post, flat payload)"); + + try { + const updated = await client.updatePost(createdPostId, { + name: `[MCP_TEST] Updated Write Proving ${timestamp}`, + body: `

Updated test post. Original timestamp: ${timestamp}. Update timestamp: ${new Date().toISOString()}

`, + is_liking_enabled: true, + }); + + recordResult({ + endpoint: `/api/admin/v2/posts/${createdPostId}`, + method: "PUT", + status: "PASS", + details: `Updated post id=${updated.id}, name="${updated.name}"`, + responseSnippet: { + id: updated.id, + name: updated.name, + status: updated.status, + is_liking_enabled: updated.is_liking_enabled, + updated_at: updated.updated_at, + }, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + recordResult({ + endpoint: `/api/admin/v2/posts/${createdPostId}`, + method: "PUT", + status: "FAIL", + details: message, + }); + } + } + + // ------------------------------------------------------------------------- + // PROVE 3: Create Comment + // POST /api/admin/v2/comments (flat payload with post_id in body) + // + // EXPECTED: 401 — admin token lacks comment-write permission. + // The endpoint returns JSON (not HTML), confirming it EXISTS. + // ------------------------------------------------------------------------- + if (createdPostId) { + console.log("\n▸ Proving POST /api/admin/v2/comments (create comment, flat payload)"); + console.log(" ⚠ Note: Expected to fail with 401 — admin token lacks comment-write permission"); + + try { + const comment = await client.createComment(createdPostId, { + body: `

Automated test comment from Circle MCP Server v0.3.0 write proving. Timestamp: ${timestamp}

`, + }); + + // If we get here, the endpoint worked (unexpected but good!) + recordResult({ + endpoint: "/api/admin/v2/comments", + method: "POST", + status: "PASS", + details: `Created comment id=${comment.id}, post_id=${comment.post_id}`, + responseSnippet: { + id: comment.id, + post_id: comment.post_id, + user_id: comment.user_id, + user_name: comment.user_name, + created_at: comment.created_at, + }, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const is401 = message.includes("401"); + + recordResult({ + endpoint: "/api/admin/v2/comments", + method: "POST", + status: is401 ? "EXPECTED_FAIL" : "FAIL", + details: is401 + ? "401 — admin token lacks comment-write permission. Endpoint exists (JSON response, not HTML 404). Contract assumed for Prompt 13." + : message, + }); + } + } + + // ------------------------------------------------------------------------- + // Summary + // ------------------------------------------------------------------------- + console.log("\n" + "═".repeat(55)); + const passed = results.filter((r) => r.status === "PASS").length; + const expectedFail = results.filter((r) => r.status === "EXPECTED_FAIL").length; + const failed = results.filter((r) => r.status === "FAIL").length; + const total = results.length; + + console.log( + `Write Endpoint Proving: ${passed}/${total} passed, ${expectedFail} expected-fail, ${failed} failed` + ); + + if (failed > 0) { + console.log("\nUnexpected failures:"); + for (const r of results.filter((r) => r.status === "FAIL")) { + console.log(` ❌ ${r.method} ${r.endpoint}: ${r.details}`); + } + } + + if (expectedFail > 0) { + console.log("\nExpected failures (documented constraints):"); + for (const r of results.filter((r) => r.status === "EXPECTED_FAIL")) { + console.log(` ⚠️ ${r.method} ${r.endpoint}: ${r.details}`); + } + } + + console.log("\nResponse snippets:"); + for (const r of results) { + if (r.responseSnippet) { + console.log(` ${r.method} ${r.endpoint}:`); + console.log(` ${JSON.stringify(r.responseSnippet, null, 2).replace(/\n/g, "\n ")}`); + } + } + + console.log(""); + + // Write results to a JSON file for the evidence doc + const fs = await import("fs"); + const outputPath = "test/prove-write-results.json"; + fs.writeFileSync(outputPath, JSON.stringify(results, null, 2)); + console.log(`Results saved to: ${outputPath}\n`); + + // Exit 0 if only passes and expected failures (no unexpected failures) + process.exit(failed > 0 ? 1 : 0); +} + +prove().catch((err: unknown) => { + console.error("Fatal error in proving script:", err); + process.exit(2); +}); diff --git a/test/smoke-http.ts b/test/smoke-http.ts new file mode 100644 index 0000000..07ab62e --- /dev/null +++ b/test/smoke-http.ts @@ -0,0 +1,199 @@ +/** + * Smoke test for the Streamable HTTP entrypoint. + * + * Starts the HTTP server as a child process, validates key endpoints, + * then shuts it down. Requires CIRCLE_API_TOKEN in env (uses a dummy + * value since no actual Circle API calls are made during the health check). + * + * Run: npx tsx test/smoke-http.ts + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { setTimeout as sleep } from "node:timers/promises"; + +const PORT = 9876; // unlikely to collide +const BASE = `http://localhost:${PORT}`; + +let server: ChildProcess | undefined; +let passed = 0; +let failed = 0; + +function assert(label: string, condition: boolean, detail?: string): void { + if (condition) { + console.log(` ✓ ${label}`); + passed++; + } else { + console.log(` ✗ ${label}${detail ? ` — ${detail}` : ""}`); + failed++; + } +} + +async function startServer(): Promise { + server = spawn("node", ["dist/http.js"], { + env: { + ...process.env, + PORT: String(PORT), + CIRCLE_API_TOKEN: "test-token-for-smoke", + MAX_SESSIONS: "5", + SESSION_TTL_MS: "10000", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + // Wait for the server to start by polling /health + for (let i = 0; i < 30; i++) { + try { + const res = await fetch(`${BASE}/health`); + if (res.ok) return; + } catch { + // not ready yet + } + await sleep(200); + } + throw new Error("Server did not start within 6 seconds"); +} + +async function stopServer(): Promise { + if (!server) return; + server.kill("SIGTERM"); + await sleep(500); + if (!server.killed) server.kill("SIGKILL"); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +async function testHealth(): Promise { + console.log("\n▸ GET /health"); + const res = await fetch(`${BASE}/health`); + const body = (await res.json()) as Record; + + assert("status 200", res.status === 200); + assert("status field is ok", body.status === "ok"); + assert("version is 0.3.0", body.version === "0.3.0"); + assert("transport is streamable-http", body.transport === "streamable-http"); + assert("sessions is 0", body.sessions === 0); + assert("maxSessions is 5", body.maxSessions === 5); +} + +async function test404(): Promise { + console.log("\n▸ GET /nonexistent"); + const res = await fetch(`${BASE}/nonexistent`); + assert("status 404", res.status === 404); +} + +async function testMethodNotAllowed(): Promise { + console.log("\n▸ PUT /mcp (unsupported method)"); + const res = await fetch(`${BASE}/mcp`, { method: "PUT" }); + assert("status 405", res.status === 405); +} + +async function testPostWithoutContentType(): Promise { + console.log("\n▸ POST /mcp without Content-Type"); + const res = await fetch(`${BASE}/mcp`, { + method: "POST", + body: "{}", + }); + // fetch sends text/plain;charset=UTF-8 by default + assert("status 415 (Unsupported Media Type)", res.status === 415); +} + +async function testPostMalformedJson(): Promise { + console.log("\n▸ POST /mcp with malformed JSON"); + const res = await fetch(`${BASE}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{not valid json", + }); + assert("status 400 (Malformed JSON)", res.status === 400); +} + +async function testGetMcpNoSession(): Promise { + console.log("\n▸ GET /mcp without session header"); + const res = await fetch(`${BASE}/mcp`); + assert("status 400 (missing session)", res.status === 400); +} + +async function testDeleteMcpBadSession(): Promise { + console.log("\n▸ DELETE /mcp with non-existent session"); + const res = await fetch(`${BASE}/mcp`, { + method: "DELETE", + headers: { "mcp-session-id": "does-not-exist" }, + }); + assert("status 404 (session not found)", res.status === 404); +} + +async function testBodySizeLimit(): Promise { + console.log("\n▸ POST /mcp with oversized body"); + // 1.5 MB of zeros + const huge = "0".repeat(1_500_000); + try { + const res = await fetch(`${BASE}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: huge, + }); + // Server may close connection (empty response) or return 413 + assert( + "rejected oversized body", + res.status === 413 || res.status === 0 || !res.ok, + ); + } catch { + // Connection reset is also acceptable for oversized body + assert("rejected oversized body (connection reset)", true); + } +} + +async function testCorsHeaders(): Promise { + console.log("\n▸ OPTIONS /mcp (CORS preflight)"); + const res = await fetch(`${BASE}/mcp`, { method: "OPTIONS" }); + assert("status 204", res.status === 204); + assert( + "Access-Control-Allow-Origin is *", + res.headers.get("access-control-allow-origin") === "*", + ); +} + +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- + +async function run(): Promise { + console.log("╔══════════════════════════════════════════════════╗"); + console.log("║ Circle MCP Server — HTTP Smoke Tests ║"); + console.log("╚══════════════════════════════════════════════════╝"); + + await startServer(); + + try { + await testHealth(); + await test404(); + await testMethodNotAllowed(); + await testPostWithoutContentType(); + await testPostMalformedJson(); + await testGetMcpNoSession(); + await testDeleteMcpBadSession(); + await testBodySizeLimit(); + await testCorsHeaders(); + } finally { + await stopServer(); + } + + console.log( + `\n══════════════════════════════════════════════════`, + ); + console.log(`Results: ${passed}/${passed + failed} passed, ${failed} failed`); + if (failed > 0) { + console.log("Some tests failed!"); + process.exit(1); + } else { + console.log("All tests passed! ✓"); + } +} + +run().catch((err) => { + console.error("Fatal:", err); + void stopServer(); + process.exit(1); +}); diff --git a/test/smoke-live.ts b/test/smoke-live.ts new file mode 100644 index 0000000..5318f4e --- /dev/null +++ b/test/smoke-live.ts @@ -0,0 +1,364 @@ +/** + * Live API smoke test for Circle MCP Server v0.1. + * + * Exercises all 6 tools against the REAL Circle API to validate: + * - HTTP client connectivity and auth + * - Response shape conformance + * - Pagination metadata accuracy + * - Error handling for known failure cases (invalid IDs) + * - Retry/backoff doesn't break happy path + * + * Requires CIRCLE_API_TOKEN in environment. + * + * Run: CIRCLE_API_TOKEN=your-token npx tsx test/smoke-live.ts + * + * Output: writes test/evidence/live-smoke-results.json + */ + +import { CircleClient } from "../dist/clients/circle-client.js"; +import { normalizeApiError, CircleApiError } from "../dist/lib/errors.js"; +import { extractPaginationSummary } from "../dist/lib/pagination.js"; +import { buildToolResponse } from "../dist/lib/responses.js"; +import { writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const EVIDENCE_DIR = join(__dirname, "evidence"); + +const token = process.env.CIRCLE_API_TOKEN?.trim(); +if (!token) { + console.error( + "ERROR: CIRCLE_API_TOKEN is not set.\n" + + "Run: CIRCLE_API_TOKEN=your-token npx tsx test/smoke-live.ts" + ); + process.exit(1); +} + +const baseUrl = ( + process.env.CIRCLE_BASE_URL?.trim() || "https://app.circle.so" +).replace(/\/+$/, ""); + +const client = new CircleClient({ apiToken: token, baseUrl }); + +// --------------------------------------------------------------------------- +// Test infrastructure +// --------------------------------------------------------------------------- + +interface LiveTestResult { + name: string; + tool: string; + passed: boolean; + durationMs: number; + error?: string; + responseShape?: string; + paginationSummary?: Record; +} + +const results: LiveTestResult[] = []; + +async function liveTest( + name: string, + tool: string, + fn: () => Promise +): Promise { + const start = Date.now(); + try { + await fn(); + const durationMs = Date.now() - start; + results.push({ name, tool, passed: true, durationMs }); + console.log(` ✓ ${name} (${durationMs}ms)`); + } catch (err: unknown) { + const durationMs = Date.now() - start; + const message = err instanceof Error ? err.message : String(err); + results.push({ name, tool, passed: false, durationMs, error: message }); + console.log(` ✗ ${name} (${durationMs}ms)`); + console.log(` → ${message}`); + } +} + +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(`Assertion failed: ${message}`); +} + +function assertHasKeys( + obj: Record, + keys: string[], + label: string +): void { + for (const key of keys) { + assert(key in obj, `${label} missing key: ${key}`); + } +} + +// --------------------------------------------------------------------------- +// Live tests +// --------------------------------------------------------------------------- + +async function runLiveTests(): Promise { + console.log("\n╔══════════════════════════════════════════════════╗"); + console.log("║ Circle MCP Server — Live API Smoke Tests ║"); + console.log("╚══════════════════════════════════════════════════╝\n"); + console.log(` Base URL: ${baseUrl}`); + console.log(` Token: ${token.slice(0, 6)}...${token.slice(-4)}\n`); + + // ------------------------------------------------------------------------- + // circle_list_spaces + // ------------------------------------------------------------------------- + console.log("▸ circle_list_spaces"); + + let firstSpaceId: number | undefined; + + await liveTest("lists spaces with defaults", "circle_list_spaces", async () => { + const response = await client.listSpaces({ per_page: 3 }); + assert(Array.isArray(response.records), "records is array"); + assert(typeof response.page === "number", "page is number"); + assert(typeof response.has_next_page === "boolean", "has_next_page is boolean"); + assert(typeof response.count === "number", "count is number"); + + const summary = extractPaginationSummary(response); + assert(summary.page === 1, "default page is 1"); + assert(summary.per_page === 3, "per_page respected"); + + if (response.records.length > 0) { + const space = response.records[0]; + assertHasKeys( + space as unknown as Record, + ["id", "name", "slug", "url"], + "space" + ); + firstSpaceId = space.id; + } + + // Verify buildToolResponse works with real data + const toolResponse = buildToolResponse({ + pagination: summary, + spaces: response.records, + }); + assert(toolResponse.content.length === 1, "one content item"); + assert(toolResponse.content[0].type === "text", "text type"); + JSON.parse(toolResponse.content[0].text); // valid JSON + }); + + await liveTest("pagination works (page 2)", "circle_list_spaces", async () => { + const response = await client.listSpaces({ page: 2, per_page: 2 }); + assert(response.page === 2, "returned page 2"); + }); + + // ------------------------------------------------------------------------- + // circle_get_space + // ------------------------------------------------------------------------- + console.log("\n▸ circle_get_space"); + + await liveTest("retrieves a space by ID", "circle_get_space", async () => { + if (!firstSpaceId) { + throw new Error("No space ID available from list_spaces — skipping"); + } + const space = await client.getSpace(firstSpaceId); + assert(space.id === firstSpaceId, "correct space returned"); + assert(typeof space.name === "string", "name is string"); + assert(typeof space.url === "string", "url is string"); + + const toolResponse = buildToolResponse(space); + assert(toolResponse.structuredContent !== undefined, "has structuredContent"); + }); + + await liveTest( + "returns error for invalid space ID (999999999)", + "circle_get_space", + async () => { + try { + await client.getSpace(999999999); + throw new Error("Expected an error for invalid ID"); + } catch (err: unknown) { + if (err instanceof Error && err.message === "Expected an error for invalid ID") { + throw err; + } + const apiErr = normalizeApiError(err, "GET /api/admin/v2/spaces/999999999"); + assert(apiErr instanceof CircleApiError, "is CircleApiError"); + assert( + apiErr.statusCode === 404 || apiErr.statusCode === 422, + `expected 404 or 422, got ${apiErr.statusCode}` + ); + } + } + ); + + // ------------------------------------------------------------------------- + // circle_list_posts + // ------------------------------------------------------------------------- + console.log("\n▸ circle_list_posts"); + + let firstPostId: number | undefined; + + await liveTest("lists posts with defaults", "circle_list_posts", async () => { + const response = await client.listPosts({ per_page: 3 }); + assert(Array.isArray(response.records), "records is array"); + assert(typeof response.count === "number", "count is number"); + + if (response.records.length > 0) { + const post = response.records[0]; + assertHasKeys( + post as unknown as Record, + ["id", "name", "slug", "status", "url", "space_id"], + "post" + ); + firstPostId = post.id; + + // Posts should have body and tiptap_body + assert(post.body !== undefined, "has body"); + assert(post.tiptap_body !== undefined, "has tiptap_body"); + } + }); + + await liveTest("filters by space_id", "circle_list_posts", async () => { + if (!firstSpaceId) return; // skip if no space + const response = await client.listPosts({ + space_id: firstSpaceId, + per_page: 2, + }); + assert(Array.isArray(response.records), "records is array"); + // All returned posts should be in the requested space + for (const post of response.records) { + assert( + post.space_id === firstSpaceId, + `post ${post.id} space_id mismatch` + ); + } + }); + + // ------------------------------------------------------------------------- + // circle_get_post + // ------------------------------------------------------------------------- + console.log("\n▸ circle_get_post"); + + await liveTest("retrieves a post by ID", "circle_get_post", async () => { + if (!firstPostId) { + throw new Error("No post ID available from list_posts — skipping"); + } + const post = await client.getPost(firstPostId); + assert(post.id === firstPostId, "correct post returned"); + assert(typeof post.name === "string", "name is string"); + assert(typeof post.tiptap_body === "object", "tiptap_body is object"); + }); + + await liveTest( + "returns error for invalid post ID (999999999)", + "circle_get_post", + async () => { + try { + await client.getPost(999999999); + throw new Error("Expected an error for invalid ID"); + } catch (err: unknown) { + if (err instanceof Error && err.message === "Expected an error for invalid ID") { + throw err; + } + const apiErr = normalizeApiError(err, "GET /api/admin/v2/posts/999999999"); + assert(apiErr instanceof CircleApiError, "is CircleApiError"); + } + } + ); + + // ------------------------------------------------------------------------- + // circle_list_members + // ------------------------------------------------------------------------- + console.log("\n▸ circle_list_members"); + + await liveTest("lists members with defaults", "circle_list_members", async () => { + const response = await client.listMembers({ per_page: 3 }); + assert(Array.isArray(response.records), "records is array"); + assert(typeof response.count === "number", "count is number"); + + if (response.records.length > 0) { + const member = response.records[0]; + assertHasKeys( + member as unknown as Record, + ["id", "name", "email", "profile_url"], + "member" + ); + } + }); + + await liveTest("status=all includes more members", "circle_list_members", async () => { + const active = await client.listMembers({ per_page: 1 }); + const all = await client.listMembers({ per_page: 1, status: "all" }); + // "all" count should be >= active count + assert( + all.count >= active.count, + `all.count (${all.count}) >= active.count (${active.count})` + ); + }); + + // ------------------------------------------------------------------------- + // circle_search + // ------------------------------------------------------------------------- + console.log("\n▸ circle_search"); + + await liveTest("searches with general query", "circle_search", async () => { + const response = await client.search({ query: "welcome", per_page: 5 }); + assert(Array.isArray(response.records), "records is array"); + assert(typeof response.count === "number", "count is number"); + + if (response.records.length > 0) { + const result = response.records[0]; + assertHasKeys( + result as unknown as Record, + ["id", "name", "type"], + "search result" + ); + } + }); + + await liveTest("search with type filter", "circle_search", async () => { + const response = await client.search({ + query: "test", + type: "spaces", + per_page: 3, + }); + assert(Array.isArray(response.records), "records is array"); + }); + + // ========================================================================= + // Summary and evidence + // ========================================================================= + console.log("\n" + "═".repeat(55)); + const passed = results.filter((r) => r.passed).length; + const failed = results.filter((r) => !r.passed).length; + const total = results.length; + + console.log(`Results: ${passed}/${total} passed, ${failed} failed`); + + // Write evidence file + mkdirSync(EVIDENCE_DIR, { recursive: true }); + const evidencePath = join(EVIDENCE_DIR, "live-smoke-results.json"); + const evidence = { + timestamp: new Date().toISOString(), + baseUrl, + tokenPrefix: token.slice(0, 6), + summary: { total, passed, failed }, + results, + }; + writeFileSync(evidencePath, JSON.stringify(evidence, null, 2)); + console.log(`\nEvidence written to: ${evidencePath}`); + + if (failed > 0) { + console.log("\nFailed tests:"); + for (const r of results.filter((r) => !r.passed)) { + console.log(` ✗ [${r.tool}] ${r.name}: ${r.error}`); + } + process.exit(1); + } else { + console.log("All live tests passed! ✓\n"); + process.exit(0); + } +} + +runLiveTests().catch((err: unknown) => { + console.error("Fatal error in live test harness:", err); + process.exit(2); +}); diff --git a/test/smoke.ts b/test/smoke.ts new file mode 100644 index 0000000..38ca931 --- /dev/null +++ b/test/smoke.ts @@ -0,0 +1,981 @@ +/** + * Smoke test harness for Circle MCP Server v0.3. + * + * Tests core infrastructure WITHOUT requiring a live Circle API token: + * - Response truncation logic + * - Error normalization and MCP formatting + * - Pagination summary extraction + * - buildToolResponse shape contract + * - toStructuredContent round-trip + * - Schema validation (rejects invalid input) + * - v0.3.0: Write schema validation + buildMutationResponse + * + * Run: npx tsx test/smoke.ts + */ + +// --------------------------------------------------------------------------- +// Minimal test runner +// --------------------------------------------------------------------------- + +interface TestResult { + name: string; + passed: boolean; + error?: string; +} + +const results: TestResult[] = []; + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +} + +function assertEqual(actual: T, expected: T, label: string): void { + if (actual !== expected) { + throw new Error( + `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}` + ); + } +} + +async function test(name: string, fn: () => void | Promise): Promise { + try { + await fn(); + results.push({ name, passed: true }); + console.log(` ✓ ${name}`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + results.push({ name, passed: false, error: message }); + console.log(` ✗ ${name}`); + console.log(` → ${message}`); + } +} + +// --------------------------------------------------------------------------- +// Imports (from compiled dist/) +// --------------------------------------------------------------------------- + +import { + RESPONSE_TEXT_LIMIT, + toStructuredContent, + prepareSafeText, +} from "../dist/types/common.js"; + +import type { TruncationNotice, SafeTextResponse } from "../dist/types/common.js"; + +import { buildToolResponse, buildMutationResponse } from "../dist/lib/responses.js"; +import type { ToolResponse } from "../dist/lib/responses.js"; + +import { + normalizeApiError, + formatErrorForMcp, + CircleApiError, +} from "../dist/lib/errors.js"; + +import { extractPaginationSummary } from "../dist/lib/pagination.js"; +import type { PaginationSummary } from "../dist/lib/pagination.js"; + +import type { CirclePaginatedResponse } from "../dist/types/common.js"; + +import { + ListSpacesInputSchema, + GetSpaceInputSchema, + ListPostsInputSchema, + GetPostInputSchema, + ListMembersInputSchema, + SearchInputSchema, + // v0.2.0 schemas + ListCommentsInputSchema, + GetCommentInputSchema, + ListTopicsInputSchema, + GetCommunityInputSchema, + ListSpaceGroupsInputSchema, + DetectUnansweredPostsInputSchema, + CommunityHealthInputSchema, + // v0.3.0 write schemas + CreatePostInputSchema, + UpdatePostInputSchema, + CreateCommentInputSchema, +} from "../dist/schemas/inputs.js"; + +// --------------------------------------------------------------------------- +// Test suites +// --------------------------------------------------------------------------- + +async function runTests(): Promise { + console.log("\n╔══════════════════════════════════════════╗"); + console.log("║ Circle MCP Server — Smoke Test Harness ║"); + console.log("╚══════════════════════════════════════════╝\n"); + + // ========================================================================= + // 1. toStructuredContent + // ========================================================================= + console.log("▸ toStructuredContent"); + + await test("converts a plain object to Record", () => { + const input = { id: 1, name: "test" }; + const result = toStructuredContent(input); + assertEqual(result.id, 1, "id"); + assertEqual(result.name, "test", "name"); + }); + + await test("deep-clones nested objects (no shared references)", () => { + const inner = { nested: true }; + const input = { data: inner }; + const result = toStructuredContent(input); + assert(result.data !== inner, "should not be same reference"); + assertEqual( + (result.data as Record).nested, + true, + "nested value preserved" + ); + }); + + await test("handles arrays correctly", () => { + const input = { items: [1, 2, 3] }; + const result = toStructuredContent(input); + assert(Array.isArray(result.items), "items should be an array"); + assertEqual((result.items as number[]).length, 3, "array length"); + }); + + await test("handles null values", () => { + const input = { value: null }; + const result = toStructuredContent(input); + assertEqual(result.value, null, "null preserved"); + }); + + // ========================================================================= + // 2. prepareSafeText + // ========================================================================= + console.log("\n▸ prepareSafeText"); + + await test("returns unchanged text when under limit", () => { + const data = { message: "hello" }; + const result: SafeTextResponse = prepareSafeText(data); + assert(result.truncation === null, "no truncation expected"); + assertEqual(result.text, JSON.stringify(data, null, 2), "text matches"); + }); + + await test("RESPONSE_TEXT_LIMIT is 100,000", () => { + assertEqual(RESPONSE_TEXT_LIMIT, 100_000, "limit value"); + }); + + await test("truncates text exceeding the limit", () => { + // Create data large enough that truncation definitely kicks in. + // The limit applies to the raw JSON, and the final output includes + // both the truncated body AND a truncation notice footer, so the + // total length can exceed `limit` — but the body portion must be + // shorter than `limit`. + const data = { content: "x".repeat(2000) }; + const limit = 500; + const result = prepareSafeText(data, limit); + + assert(result.truncation !== null, "truncation should be set"); + assert(result.truncation!.truncated === true, "truncated flag"); + assert( + result.truncation!.original_text_length > limit, + "original length exceeds limit" + ); + // The truncated text should be smaller than the original + assert( + result.text.length < result.truncation!.original_text_length, + "truncated text is shorter than original" + ); + assert( + result.text.includes("TRUNCATION NOTICE"), + "contains truncation notice" + ); + }); + + await test("truncation notice includes actionable message", () => { + const data = { content: "y".repeat(500) }; + const result = prepareSafeText(data, 100); + + assert(result.truncation !== null, "truncation occurred"); + assert( + result.truncation!.message.includes("pagination"), + "message mentions pagination" + ); + assert( + result.truncation!.message.includes("per_page"), + "message mentions per_page" + ); + }); + + await test("handles exact limit boundary (no truncation)", () => { + const data = "a"; // very short + const result = prepareSafeText(data, 10_000); + assert(result.truncation === null, "should not truncate short data"); + }); + + // ========================================================================= + // 3. buildToolResponse + // ========================================================================= + console.log("\n▸ buildToolResponse"); + + await test("returns correct shape with content and structuredContent", () => { + const data = { id: 42, name: "Test Space" }; + const response: ToolResponse = buildToolResponse(data); + + assert(Array.isArray(response.content), "content is array"); + assertEqual(response.content.length, 1, "one content item"); + assertEqual(response.content[0].type, "text", "content type is text"); + assert( + typeof response.content[0].text === "string", + "text is string" + ); + assert( + typeof response.structuredContent === "object", + "structuredContent is object" + ); + assertEqual( + (response.structuredContent as Record).id, + 42, + "structuredContent.id" + ); + }); + + await test("text content is valid JSON", () => { + const data = { count: 5, items: ["a", "b"] }; + const response = buildToolResponse(data); + const parsed = JSON.parse(response.content[0].text); + assertEqual(parsed.count, 5, "parsed count"); + assertEqual(parsed.items.length, 2, "parsed items length"); + }); + + await test("has index signature (compatible with MCP SDK)", () => { + const response = buildToolResponse({ x: 1 }); + // The key test: we can index with arbitrary strings without type error + const keys = Object.keys(response); + assert(keys.includes("content"), "has content key"); + assert(keys.includes("structuredContent"), "has structuredContent key"); + }); + + // ========================================================================= + // 4. Error normalization + // ========================================================================= + console.log("\n▸ Error normalization"); + + await test("normalizeApiError wraps standard Error", () => { + const err = new Error("connection reset"); + const result = normalizeApiError(err, "GET /test"); + assert(result instanceof CircleApiError, "is CircleApiError"); + assert( + result.message.includes("connection reset"), + "preserves message" + ); + assertEqual(result.endpoint, "GET /test", "endpoint"); + }); + + await test("normalizeApiError passes through CircleApiError", () => { + const original = new CircleApiError("already wrapped", 404, "GET /x"); + const result = normalizeApiError(original, "GET /y"); + assert(result === original, "same reference returned"); + }); + + await test("normalizeApiError handles unknown value", () => { + const result = normalizeApiError("string error", "GET /unknown"); + assert(result instanceof CircleApiError, "is CircleApiError"); + assert( + result.message.includes("string error"), + "includes the unknown value" + ); + }); + + await test("normalizeApiError handles AbortError (timeout)", () => { + const abort = new DOMException("signal aborted", "AbortError"); + const result = normalizeApiError(abort, "GET /slow"); + assert(result.message.includes("timed out"), "mentions timeout"); + }); + + // ========================================================================= + // 5. formatErrorForMcp + // ========================================================================= + console.log("\n▸ formatErrorForMcp"); + + await test("returns isError: true with text content", () => { + const err = new CircleApiError("not found", 404, "GET /posts/999"); + const result = formatErrorForMcp(err); + + assertEqual(result.isError, true, "isError flag"); + assert(Array.isArray(result.content), "content is array"); + assertEqual(result.content[0].type, "text", "content type"); + assert( + result.content[0].text.includes("not found"), + "error message in text" + ); + }); + + // ========================================================================= + // 6. Pagination summary extraction + // ========================================================================= + console.log("\n▸ extractPaginationSummary"); + + await test("extracts all pagination fields correctly", () => { + const response: CirclePaginatedResponse<{ id: number }> = { + page: 2, + per_page: 10, + has_next_page: true, + count: 42, + page_count: 5, + records: [{ id: 1 }, { id: 2 }, { id: 3 }], + }; + + const summary: PaginationSummary = extractPaginationSummary(response); + + assertEqual(summary.page, 2, "page"); + assertEqual(summary.per_page, 10, "per_page"); + assertEqual(summary.has_next_page, true, "has_next_page"); + assertEqual(summary.total_count, 42, "total_count"); + assertEqual(summary.total_pages, 5, "total_pages"); + assertEqual(summary.records_on_page, 3, "records_on_page"); + }); + + await test("handles empty records array", () => { + const response: CirclePaginatedResponse = { + page: 1, + per_page: 10, + has_next_page: false, + count: 0, + page_count: 0, + records: [], + }; + + const summary = extractPaginationSummary(response); + assertEqual(summary.records_on_page, 0, "zero records"); + assertEqual(summary.has_next_page, false, "no next page"); + }); + + // ========================================================================= + // 7. Schema validation (input schemas reject bad input) + // ========================================================================= + console.log("\n▸ Schema validation"); + + await test("ListSpacesInputSchema accepts valid input", () => { + const result = ListSpacesInputSchema.safeParse({ + page: 1, + per_page: 10, + sort: "active", + }); + assert(result.success, "valid input accepted"); + }); + + await test("ListSpacesInputSchema accepts empty input", () => { + const result = ListSpacesInputSchema.safeParse({}); + assert(result.success, "empty input accepted (all optional)"); + }); + + await test("ListSpacesInputSchema rejects per_page > 100", () => { + const result = ListSpacesInputSchema.safeParse({ per_page: 200 }); + assert(!result.success, "per_page > 100 rejected"); + }); + + await test("ListSpacesInputSchema rejects unknown sort value", () => { + const result = ListSpacesInputSchema.safeParse({ sort: "invalid" }); + assert(!result.success, "invalid sort rejected"); + }); + + await test("ListSpacesInputSchema rejects extra properties (strict)", () => { + const result = ListSpacesInputSchema.safeParse({ foo: "bar" }); + assert(!result.success, "extra properties rejected"); + }); + + await test("GetSpaceInputSchema requires space_id", () => { + const result = GetSpaceInputSchema.safeParse({}); + assert(!result.success, "missing space_id rejected"); + }); + + await test("GetSpaceInputSchema rejects negative space_id", () => { + const result = GetSpaceInputSchema.safeParse({ space_id: -1 }); + assert(!result.success, "negative space_id rejected"); + }); + + await test("GetSpaceInputSchema rejects non-integer space_id", () => { + const result = GetSpaceInputSchema.safeParse({ space_id: 1.5 }); + assert(!result.success, "float space_id rejected"); + }); + + await test("ListPostsInputSchema accepts full filter set", () => { + const result = ListPostsInputSchema.safeParse({ + page: 1, + per_page: 5, + space_id: 100, + status: "published", + search_text: "hello", + sort: "latest", + }); + assert(result.success, "valid full input accepted"); + }); + + await test("ListPostsInputSchema rejects invalid status", () => { + const result = ListPostsInputSchema.safeParse({ status: "pending" }); + assert(!result.success, "invalid status rejected"); + }); + + await test("GetPostInputSchema requires post_id", () => { + const result = GetPostInputSchema.safeParse({}); + assert(!result.success, "missing post_id rejected"); + }); + + await test("ListMembersInputSchema accepts status filter", () => { + const result = ListMembersInputSchema.safeParse({ status: "all" }); + assert(result.success, "status=all accepted"); + }); + + await test("ListMembersInputSchema rejects unknown status", () => { + const result = ListMembersInputSchema.safeParse({ status: "active" }); + assert(!result.success, "status=active rejected (not in enum)"); + }); + + await test("SearchInputSchema requires query", () => { + const result = SearchInputSchema.safeParse({}); + assert(!result.success, "missing query rejected"); + }); + + await test("SearchInputSchema rejects empty query", () => { + const result = SearchInputSchema.safeParse({ query: "" }); + assert(!result.success, "empty query rejected"); + }); + + await test("SearchInputSchema accepts type filter", () => { + const result = SearchInputSchema.safeParse({ + query: "test", + type: "posts", + }); + assert(result.success, "type=posts accepted"); + }); + + await test("SearchInputSchema rejects unknown type", () => { + const result = SearchInputSchema.safeParse({ + query: "test", + type: "widgets", + }); + assert(!result.success, "unknown type rejected"); + }); + + // ========================================================================= + // 8. v0.2.0 Schema validation + // ========================================================================= + console.log("\n▸ v0.2.0 Schema validation"); + + // --- ListCommentsInputSchema --- + await test("ListCommentsInputSchema accepts empty input", () => { + const result = ListCommentsInputSchema.safeParse({}); + assert(result.success, "empty input accepted (all optional)"); + }); + + await test("ListCommentsInputSchema accepts post_id filter", () => { + const result = ListCommentsInputSchema.safeParse({ post_id: 42 }); + assert(result.success, "post_id filter accepted"); + }); + + await test("ListCommentsInputSchema accepts space_id filter", () => { + const result = ListCommentsInputSchema.safeParse({ space_id: 10 }); + assert(result.success, "space_id filter accepted"); + }); + + await test("ListCommentsInputSchema rejects extra properties", () => { + const result = ListCommentsInputSchema.safeParse({ unknown: true }); + assert(!result.success, "extra properties rejected (strict)"); + }); + + await test("ListCommentsInputSchema rejects negative post_id", () => { + const result = ListCommentsInputSchema.safeParse({ post_id: -1 }); + assert(!result.success, "negative post_id rejected"); + }); + + // --- GetCommentInputSchema --- + await test("GetCommentInputSchema requires comment_id", () => { + const result = GetCommentInputSchema.safeParse({}); + assert(!result.success, "missing comment_id rejected"); + }); + + await test("GetCommentInputSchema accepts valid comment_id", () => { + const result = GetCommentInputSchema.safeParse({ comment_id: 123 }); + assert(result.success, "valid comment_id accepted"); + }); + + await test("GetCommentInputSchema rejects float comment_id", () => { + const result = GetCommentInputSchema.safeParse({ comment_id: 1.5 }); + assert(!result.success, "float comment_id rejected"); + }); + + // --- ListTopicsInputSchema --- + await test("ListTopicsInputSchema accepts empty input", () => { + const result = ListTopicsInputSchema.safeParse({}); + assert(result.success, "empty input accepted"); + }); + + await test("ListTopicsInputSchema accepts pagination", () => { + const result = ListTopicsInputSchema.safeParse({ page: 2, per_page: 20 }); + assert(result.success, "pagination accepted"); + }); + + await test("ListTopicsInputSchema rejects extra properties", () => { + const result = ListTopicsInputSchema.safeParse({ topic_id: 1 }); + assert(!result.success, "extra properties rejected (strict)"); + }); + + // --- GetCommunityInputSchema --- + await test("GetCommunityInputSchema accepts empty object", () => { + const result = GetCommunityInputSchema.safeParse({}); + assert(result.success, "empty object accepted (no params needed)"); + }); + + await test("GetCommunityInputSchema rejects any parameters", () => { + const result = GetCommunityInputSchema.safeParse({ id: 1 }); + assert(!result.success, "parameters rejected (strict empty object)"); + }); + + // --- ListSpaceGroupsInputSchema --- + await test("ListSpaceGroupsInputSchema accepts empty input", () => { + const result = ListSpaceGroupsInputSchema.safeParse({}); + assert(result.success, "empty input accepted"); + }); + + await test("ListSpaceGroupsInputSchema rejects per_page > 100", () => { + const result = ListSpaceGroupsInputSchema.safeParse({ per_page: 150 }); + assert(!result.success, "per_page > 100 rejected"); + }); + + // --- DetectUnansweredPostsInputSchema --- + await test("DetectUnansweredPostsInputSchema accepts empty input", () => { + const result = DetectUnansweredPostsInputSchema.safeParse({}); + assert(result.success, "empty input accepted (all optional)"); + }); + + await test("DetectUnansweredPostsInputSchema accepts space_id", () => { + const result = DetectUnansweredPostsInputSchema.safeParse({ space_id: 5 }); + assert(result.success, "space_id filter accepted"); + }); + + await test("DetectUnansweredPostsInputSchema rejects per_page > 100", () => { + const result = DetectUnansweredPostsInputSchema.safeParse({ per_page: 200 }); + assert(!result.success, "per_page > 100 rejected"); + }); + + await test("DetectUnansweredPostsInputSchema rejects extra properties", () => { + const result = DetectUnansweredPostsInputSchema.safeParse({ status: "draft" }); + assert(!result.success, "extra properties rejected (strict)"); + }); + + // --- CommunityHealthInputSchema --- + await test("CommunityHealthInputSchema accepts empty object", () => { + const result = CommunityHealthInputSchema.safeParse({}); + assert(result.success, "empty object accepted (no params needed)"); + }); + + await test("CommunityHealthInputSchema rejects any parameters", () => { + const result = CommunityHealthInputSchema.safeParse({ days: 30 }); + assert(!result.success, "parameters rejected (strict empty object)"); + }); + + // ========================================================================= + // 9. v0.3.0 Write Schema validation + // ========================================================================= + console.log("\n▸ v0.3.0 Write Schema validation"); + + // --- CreatePostInputSchema --- + await test("CreatePostInputSchema accepts valid full input", () => { + const result = CreatePostInputSchema.safeParse({ + space_id: 100, + name: "Test Post", + body: "

Hello world

", + status: "draft", + is_comments_enabled: true, + is_liking_enabled: false, + skip_notifications: true, + }); + assert(result.success, "valid full input accepted"); + }); + + await test("CreatePostInputSchema requires space_id", () => { + const result = CreatePostInputSchema.safeParse({ + name: "Test", + body: "

Hello

", + }); + assert(!result.success, "missing space_id rejected"); + }); + + await test("CreatePostInputSchema requires name", () => { + const result = CreatePostInputSchema.safeParse({ + space_id: 1, + body: "

Hello

", + }); + assert(!result.success, "missing name rejected"); + }); + + await test("CreatePostInputSchema requires body", () => { + const result = CreatePostInputSchema.safeParse({ + space_id: 1, + name: "Test", + }); + assert(!result.success, "missing body rejected"); + }); + + await test("CreatePostInputSchema rejects empty name", () => { + const result = CreatePostInputSchema.safeParse({ + space_id: 1, + name: "", + body: "

Hello

", + }); + assert(!result.success, "empty name rejected"); + }); + + await test("CreatePostInputSchema rejects empty body", () => { + const result = CreatePostInputSchema.safeParse({ + space_id: 1, + name: "Test", + body: "", + }); + assert(!result.success, "empty body rejected"); + }); + + await test("CreatePostInputSchema rejects invalid status", () => { + const result = CreatePostInputSchema.safeParse({ + space_id: 1, + name: "Test", + body: "

Hello

", + status: "archived", + }); + assert(!result.success, "invalid status rejected"); + }); + + await test("CreatePostInputSchema rejects extra properties (strict)", () => { + const result = CreatePostInputSchema.safeParse({ + space_id: 1, + name: "Test", + body: "

Hello

", + unknown_field: true, + }); + assert(!result.success, "extra properties rejected"); + }); + + await test("CreatePostInputSchema accepts minimal required input", () => { + const result = CreatePostInputSchema.safeParse({ + space_id: 1, + name: "Test", + body: "

Hello

", + }); + assert(result.success, "minimal required input accepted"); + }); + + await test("CreatePostInputSchema rejects negative space_id", () => { + const result = CreatePostInputSchema.safeParse({ + space_id: -1, + name: "Test", + body: "

Hello

", + }); + assert(!result.success, "negative space_id rejected"); + }); + + // --- UpdatePostInputSchema --- + await test("UpdatePostInputSchema requires post_id", () => { + const result = UpdatePostInputSchema.safeParse({ + name: "Updated Title", + }); + assert(!result.success, "missing post_id rejected"); + }); + + await test("UpdatePostInputSchema accepts post_id only", () => { + const result = UpdatePostInputSchema.safeParse({ + post_id: 42, + }); + assert(result.success, "post_id only accepted (all other fields optional)"); + }); + + await test("UpdatePostInputSchema accepts full update input", () => { + const result = UpdatePostInputSchema.safeParse({ + post_id: 42, + name: "Updated Title", + body: "

Updated body

", + status: "published", + is_comments_enabled: false, + is_liking_enabled: true, + }); + assert(result.success, "valid full update accepted"); + }); + + await test("UpdatePostInputSchema rejects invalid status", () => { + const result = UpdatePostInputSchema.safeParse({ + post_id: 42, + status: "scheduled", + }); + assert(!result.success, "invalid status rejected"); + }); + + await test("UpdatePostInputSchema rejects extra properties (strict)", () => { + const result = UpdatePostInputSchema.safeParse({ + post_id: 42, + space_id: 10, + }); + assert(!result.success, "extra properties rejected"); + }); + + await test("UpdatePostInputSchema rejects negative post_id", () => { + const result = UpdatePostInputSchema.safeParse({ + post_id: -1, + }); + assert(!result.success, "negative post_id rejected"); + }); + + await test("UpdatePostInputSchema rejects float post_id", () => { + const result = UpdatePostInputSchema.safeParse({ + post_id: 1.5, + }); + assert(!result.success, "float post_id rejected"); + }); + + // --- CreateCommentInputSchema --- + await test("CreateCommentInputSchema accepts valid input", () => { + const result = CreateCommentInputSchema.safeParse({ + post_id: 42, + body: "

Great post!

", + }); + assert(result.success, "valid input accepted"); + }); + + await test("CreateCommentInputSchema requires post_id", () => { + const result = CreateCommentInputSchema.safeParse({ + body: "

Comment

", + }); + assert(!result.success, "missing post_id rejected"); + }); + + await test("CreateCommentInputSchema requires body", () => { + const result = CreateCommentInputSchema.safeParse({ + post_id: 42, + }); + assert(!result.success, "missing body rejected"); + }); + + await test("CreateCommentInputSchema rejects empty body", () => { + const result = CreateCommentInputSchema.safeParse({ + post_id: 42, + body: "", + }); + assert(!result.success, "empty body rejected"); + }); + + await test("CreateCommentInputSchema rejects extra properties (strict)", () => { + const result = CreateCommentInputSchema.safeParse({ + post_id: 42, + body: "

Comment

", + user_id: 10, + }); + assert(!result.success, "extra properties rejected"); + }); + + await test("CreateCommentInputSchema rejects negative post_id", () => { + const result = CreateCommentInputSchema.safeParse({ + post_id: -1, + body: "

Comment

", + }); + assert(!result.success, "negative post_id rejected"); + }); + + // ========================================================================= + // 10. buildMutationResponse + // ========================================================================= + console.log("\n▸ buildMutationResponse"); + + await test("buildMutationResponse wraps data in MutationResult envelope", () => { + const data = { id: 42, name: "Test Post" }; + const response: ToolResponse = buildMutationResponse("create", data, "POST /api/admin/v2/posts"); + + assert(Array.isArray(response.content), "content is array"); + assertEqual(response.content.length, 1, "one content item"); + assertEqual(response.content[0].type, "text", "content type is text"); + + const parsed = JSON.parse(response.content[0].text); + assertEqual(parsed.operation, "create", "operation is create"); + assertEqual(parsed.success, true, "success is true"); + assertEqual(parsed.endpoint, "POST /api/admin/v2/posts", "endpoint preserved"); + assertEqual(parsed.data.id, 42, "data.id preserved"); + assertEqual(parsed.data.name, "Test Post", "data.name preserved"); + }); + + await test("buildMutationResponse sets operation to update", () => { + const data = { id: 42, name: "Updated" }; + const response = buildMutationResponse("update", data, "PUT /api/admin/v2/posts/42"); + + const parsed = JSON.parse(response.content[0].text); + assertEqual(parsed.operation, "update", "operation is update"); + assertEqual(parsed.success, true, "success is true"); + assertEqual(parsed.endpoint, "PUT /api/admin/v2/posts/42", "endpoint preserved"); + }); + + await test("buildMutationResponse structuredContent has MutationResult shape", () => { + const data = { id: 1 }; + const response = buildMutationResponse("create", data, "/test"); + + const sc = response.structuredContent as Record; + assertEqual(sc.operation, "create", "structuredContent.operation"); + assertEqual(sc.success, true, "structuredContent.success"); + assertEqual(sc.endpoint, "/test", "structuredContent.endpoint"); + assert(typeof sc.data === "object", "structuredContent.data is object"); + }); + + await test("buildMutationResponse text is valid JSON", () => { + const data = { count: 5, items: ["a", "b"] }; + const response = buildMutationResponse("create", data, "/test"); + const parsed = JSON.parse(response.content[0].text); + assertEqual(parsed.data.count, 5, "nested data.count"); + assertEqual(parsed.data.items.length, 2, "nested data.items length"); + }); + + // ========================================================================= + // 11. v0.3.0 Write Tool Handler Tests + // ========================================================================= + console.log("\n▸ v0.3.0 Write Tool Handler Tests"); + + // Test that write tool modules export register functions + await test("create-post module exports registerCreatePost", async () => { + const mod = await import("../dist/tools/create-post.js"); + assert(typeof mod.registerCreatePost === "function", "registerCreatePost is a function"); + }); + + await test("update-post module exports registerUpdatePost", async () => { + const mod = await import("../dist/tools/update-post.js"); + assert(typeof mod.registerUpdatePost === "function", "registerUpdatePost is a function"); + }); + + await test("create-comment module exports registerCreateComment", async () => { + const mod = await import("../dist/tools/create-comment.js"); + assert(typeof mod.registerCreateComment === "function", "registerCreateComment is a function"); + }); + + // Test that the tools index registers all 16 tools (including write tools) + await test("tools/index exports registerAllTools", async () => { + const mod = await import("../dist/tools/index.js"); + assert(typeof mod.registerAllTools === "function", "registerAllTools is a function"); + }); + + // Test buildMutationResponse with CirclePost-like data + await test("buildMutationResponse handles CirclePost-shaped data", () => { + const postData = { + id: 30565045, + name: "[MCP_TEST] Write Tool Test", + slug: "mcp-test-write-tool-test", + status: "draft", + space_id: 12345, + comments_count: 0, + likes_count: 0, + }; + const response = buildMutationResponse("create", postData, "POST /api/admin/v2/posts"); + const parsed = JSON.parse(response.content[0].text); + + assertEqual(parsed.operation, "create", "operation is create"); + assertEqual(parsed.success, true, "success is true"); + assertEqual(parsed.data.id, 30565045, "post id preserved"); + assertEqual(parsed.data.status, "draft", "post status preserved"); + assertEqual(parsed.data.space_id, 12345, "space_id preserved"); + }); + + // Test error formatting for mutation endpoints + await test("normalizeApiError produces actionable message for 400 on write endpoint", () => { + const error = Object.assign(new Error("Bad Request"), { status: 400 }); + const normalized = normalizeApiError(error, "POST /api/admin/v2/posts"); + assert(normalized.message.includes("400"), "includes status code"); + assert(normalized.message.includes("POST /api/admin/v2/posts"), "includes endpoint"); + assert(normalized.message.includes("required fields"), "includes actionable hint"); + }); + + await test("normalizeApiError produces actionable message for 401 on comment endpoint", () => { + const error = Object.assign(new Error("Unauthorized"), { status: 401 }); + const normalized = normalizeApiError(error, "POST /api/admin/v2/comments"); + assertEqual(normalized.statusCode, 401, "status code is 401"); + assert(normalized.message.includes("401"), "includes status code"); + assert(normalized.message.includes("CIRCLE_API_TOKEN"), "includes token hint"); + }); + + await test("normalizeApiError handles 409 Conflict for mutations", () => { + const error = Object.assign(new Error("Conflict"), { status: 409 }); + const normalized = normalizeApiError(error, "POST /api/admin/v2/posts"); + assertEqual(normalized.statusCode, 409, "status code is 409"); + assert(normalized.message.includes("409"), "includes status code"); + assert(normalized.message.includes("Conflict"), "includes conflict context"); + }); + + // Test that write schemas destructure correctly for handler patterns + await test("CreatePostInputSchema destructures space_id from rest fields", () => { + const input = { + space_id: 100, + name: "Test", + body: "

Hello

", + status: "draft" as const, + skip_notifications: true, + }; + const result = CreatePostInputSchema.safeParse(input); + assert(result.success, "valid input parsed"); + if (result.success) { + const { space_id, ...rest } = result.data; + assertEqual(space_id, 100, "space_id destructured"); + assertEqual(rest.name, "Test", "name in rest"); + assertEqual(rest.body, "

Hello

", "body in rest"); + assertEqual(rest.status, "draft", "status in rest"); + assertEqual(rest.skip_notifications, true, "skip_notifications in rest"); + } + }); + + await test("UpdatePostInputSchema destructures post_id from rest fields", () => { + const input = { + post_id: 42, + name: "Updated Title", + is_liking_enabled: false, + }; + const result = UpdatePostInputSchema.safeParse(input); + assert(result.success, "valid input parsed"); + if (result.success) { + const { post_id, ...rest } = result.data; + assertEqual(post_id, 42, "post_id destructured"); + assertEqual(rest.name, "Updated Title", "name in rest"); + assertEqual(rest.is_liking_enabled, false, "is_liking_enabled in rest"); + assert(!("post_id" in rest), "post_id not in rest"); + } + }); + + await test("CreateCommentInputSchema destructures post_id from rest fields", () => { + const input = { + post_id: 42, + body: "

Comment text

", + }; + const result = CreateCommentInputSchema.safeParse(input); + assert(result.success, "valid input parsed"); + if (result.success) { + const { post_id, ...rest } = result.data; + assertEqual(post_id, 42, "post_id destructured"); + assertEqual(rest.body, "

Comment text

", "body in rest"); + assert(!("post_id" in rest), "post_id not in rest"); + } + }); + + // ========================================================================= + // Summary + // ========================================================================= + console.log("\n" + "═".repeat(50)); + const passed = results.filter((r) => r.passed).length; + const failed = results.filter((r) => !r.passed).length; + const total = results.length; + + console.log(`Results: ${passed}/${total} passed, ${failed} failed`); + + if (failed > 0) { + console.log("\nFailed tests:"); + for (const r of results.filter((r) => !r.passed)) { + console.log(` ✗ ${r.name}: ${r.error}`); + } + console.log(""); + process.exit(1); + } else { + console.log("All tests passed! ✓\n"); + process.exit(0); + } +} + +// Run +runTests().catch((err: unknown) => { + console.error("Fatal error in test harness:", err); + process.exit(2); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6aa4b1d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}