diff --git a/README.md b/README.md index 82eebbf..5233623 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @aredotna/mcp -MCP server for the [Are.na](https://www.are.na) API. Provides 40+ auto-generated tools from the OpenAPI spec plus custom composite tools. +MCP server for the [Are.na](https://www.are.na) API. Provides 50 auto-generated tools from the OpenAPI spec plus custom composite tools. ## Quick start @@ -208,4 +208,10 @@ When the Are.na API spec changes: yarn generate ``` -This fetches the latest spec and regenerates TypeScript types and tool registrations. +This fetches the latest spec and regenerates TypeScript types and tool registrations. To regenerate from the checked-in spec without fetching it again: + +```bash +yarn generate --offline +``` + +The server uses `openapi-fetch` with generated types directly. Custom tools use the same generated response types as the endpoint tools. diff --git a/scripts/generate-tools.ts b/scripts/generate-tools.ts index fc444fe..d53c51b 100644 --- a/scripts/generate-tools.ts +++ b/scripts/generate-tools.ts @@ -1,5 +1,5 @@ import { readFileSync, writeFileSync } from "node:fs"; -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { jsonSchemaToZodString } from "../src/lib/json-schema-to-zod"; const SPEC_URL = "https://api.are.na/v3/openapi.json"; @@ -304,17 +304,32 @@ function generateHandler(tool: ToolDef): string { } function main() { - console.log("Fetching OpenAPI spec..."); - execSync(`curl -s ${SPEC_URL} -o ${SPEC_PATH}`); + const offline = process.argv.includes("--offline"); + console.log( + offline ? "Using checked-in OpenAPI spec..." : "Fetching OpenAPI spec...", + ); + const spec = JSON.parse( + offline + ? readFileSync(SPEC_PATH, "utf-8") + : execFileSync( + "curl", + ["--fail", "--silent", "--show-error", "--location", SPEC_URL], + { + encoding: "utf-8", + }, + ), + ) as Record; + if (!spec.openapi || !spec.paths || !spec.components) { + throw new Error( + "Expected an OpenAPI specification with paths and components", + ); + } + writeFileSync(SPEC_PATH, `${JSON.stringify(spec, null, 2)}\n`); console.log("Generating TypeScript types..."); execSync(`npx openapi-typescript ${SPEC_PATH} -o ${TYPES_PATH}`); console.log("Generating tool registrations..."); - const spec = JSON.parse(readFileSync(SPEC_PATH, "utf-8")) as Record< - string, - unknown - >; const paths = spec.paths as Record>; const tools: ToolDef[] = []; @@ -369,7 +384,7 @@ function main() { writeFileSync(TOOLS_PATH, lines.join("\n")); writeFileSync(SPEC_PATH, `${JSON.stringify(spec, null, 2)}\n`); - execSync(`npx prettier --write ${TOOLS_PATH} ${SPEC_PATH}`, { + execSync(`npx prettier --write ${TOOLS_PATH} ${SPEC_PATH} ${TYPES_PATH}`, { stdio: "inherit", }); console.log(`Generated ${tools.length} tools → ${TOOLS_PATH}`); diff --git a/src/__tests__/api-tools.test.ts b/src/__tests__/api-tools.test.ts new file mode 100644 index 0000000..cd0af61 --- /dev/null +++ b/src/__tests__/api-tools.test.ts @@ -0,0 +1,217 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import app from "../transports/http"; +import spec from "../generated/openapi.json"; + +afterEach(() => vi.unstubAllGlobals()); + +async function rpc(method: string, params: Record = {}) { + const response = await app.request("http://localhost/mcp", { + method: "POST", + headers: { + Authorization: "Bearer test-token", + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "MCP-Protocol-Version": "2025-03-26", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + expect(response.status).toBe(200); + const body = await response.text(); + const event = body.split("\n").find((line) => line.startsWith("data: ")); + const message = JSON.parse(event ? event.slice(6) : body); + expect(message.error).toBeUndefined(); + return message.result; +} + +function mockApi(responses: unknown[]) { + const fetch = vi.fn(async (_request: Request) => { + const response = responses.shift(); + return response instanceof Response ? response : Response.json(response); + }); + vi.stubGlobal("fetch", fetch); + return fetch; +} + +describe("current Are.na API tools", () => { + it("exposes every supported API operation plus the custom tools", async () => { + const excluded = new Set([ + "createOAuthToken", + "getOpenapiSpec", + "getOpenapiSpecJson", + "getPing", + ]); + const operations = Object.values(spec.paths).flatMap((methods) => + Object.values(methods).flatMap((operation) => + "operationId" in operation && !excluded.has(operation.operationId) + ? [operation.operationId] + : [], + ), + ); + const result = await rpc("tools/list"); + expect( + result.tools.map((tool: { name: string }) => tool.name).sort(), + ).toEqual([...operations, "searchAndConnect", "channelDigest"].sort()); + }); + + it("returns valid MCP text for successful empty API responses", async () => { + mockApi([new Response(null, { status: 204 })]); + const result = await rpc("tools/call", { + name: "deleteComment", + arguments: { id: 1 }, + }); + expect(result.isError).toBeUndefined(); + expect(result.content).toEqual([{ type: "text", text: "null" }]); + }); + + it("connects v3 search results using type and skips non-connectable entities", async () => { + const fetch = mockApi([ + { + data: [ + { id: 1, type: "Image" }, + { id: 2, type: "Channel" }, + { id: 3, type: "User" }, + ], + }, + { data: [] }, + { data: [] }, + ]); + const result = await rpc("tools/call", { + name: "searchAndConnect", + arguments: { query: "architecture", target_channel_id: "research" }, + }); + expect(result.isError).toBeUndefined(); + expect(JSON.parse(result.content[0].text)).toMatchObject({ + found: 3, + connected: 2, + skipped: [{ id: 3, type: "User" }], + items: [ + { id: 1, type: "Block" }, + { id: 2, type: "Channel" }, + ], + }); + const requests = fetch.mock.calls; + expect(await requests[1][0].json()).toEqual({ + connectable_id: 1, + connectable_type: "Block", + channel_ids: ["research"], + }); + expect(await requests[2][0].json()).toEqual({ + connectable_id: 2, + connectable_type: "Channel", + channel_ids: ["research"], + }); + expect(requests[1][0].headers.get("Authorization")).toBe( + "Bearer test-token", + ); + }); + + it("summarizes current description, entity types, and connection activity", async () => { + mockApi([ + { + id: 10, + title: "Research", + slug: "research", + visibility: "closed", + description: { + plain: "Plain description", + markdown: "Plain **description**", + html: "

Plain description

", + }, + created_at: "2026-01-01", + updated_at: "2026-10-20", + owner: { slug: "studio", name: "Studio", type: "Group" }, + collaborators: [ + { slug: "collaborator", name: "Collaborator", type: "User" }, + ], + counts: { blocks: 2, channels: 1, contents: 3, collaborators: 1 }, + }, + { + data: [ + { + id: 1, + type: "Image", + title: "Photo", + created_at: "2026-01-01", + user: { slug: "author" }, + connection: { + connected_at: "2026-10-20", + connected_by: { slug: "collector" }, + }, + }, + { + id: 2, + type: "Channel", + title: "Related", + created_at: "2026-01-01", + connection: { connected_at: "2026-10-19", connected_by: null }, + }, + ], + }, + ]); + const result = await rpc("tools/call", { + name: "channelDigest", + arguments: { channel_id: "research", sample_size: 2 }, + }); + expect(result.isError).toBeUndefined(); + expect(JSON.parse(result.content[0].text)).toMatchObject({ + channel: { description: "Plain description" }, + owner: { type: "Group" }, + collaborators: [{ type: "User" }], + type_breakdown: { + Image: 1, + Channel: 1, + note: "Based on 2 most recent items out of 3 total", + }, + active_contributors: ["collector"], + recent_items: [ + { id: 1, type: "Image", added: "2026-10-20", added_by: "collector" }, + { id: 2, type: "Channel", added: "2026-10-19" }, + ], + }); + }); + + it("forwards structured destinations, cover URLs, and scalar metadata", async () => { + const fetch = mockApi([{ id: 1, type: "Link" }]); + const args = { + value: "https://example.com", + cover_url: "https://example.com/cover.jpg", + metadata: { source: "research", score: 0.5, reviewed: true }, + channels: [ + { id: "research", position: 1, metadata: { section: "intro" } }, + ], + }; + const result = await rpc("tools/call", { + name: "createBlock", + arguments: args, + }); + expect(result.isError).toBeUndefined(); + const request = fetch.mock.calls[0][0]; + expect(await request.json()).toEqual(args); + }); + + it("preserves null metadata deletions", async () => { + const fetch = mockApi([{ id: 1 }]); + const result = await rpc("tools/call", { + name: "updateBlock", + arguments: { id: 1, metadata: { status: null } }, + }); + expect(result.isError).toBeUndefined(); + const request = fetch.mock.calls[0][0]; + expect(await request.json()).toEqual({ metadata: { status: null } }); + }); + + it.each([ + ["getChannelContents", { id: "research", per: 101 }], + ["getChannelContents", { id: "research", page: 0 }], + ["updateBlock", { id: 1, metadata: { nested: { unsupported: true } } }], + ["updateBlock", { id: 1, metadata: { too_long: "a".repeat(2001) } }], + ])( + "rejects invalid %s inputs before requesting the API", + async (name, args) => { + const fetch = mockApi([]); + const result = await rpc("tools/call", { name, arguments: args }); + expect(result.isError).toBe(true); + expect(fetch).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/src/generated/openapi.json b/src/generated/openapi.json index 651241c..6d1b553 100644 --- a/src/generated/openapi.json +++ b/src/generated/openapi.json @@ -8,7 +8,7 @@ "url": "https://www.are.na/about#contact", "email": "help@are.na" }, - "description": "The Are.na REST API provides programmatic access to the Are.na platform - a tool for connecting ideas and building knowledge together.\n\n## Getting Started\n\nAll API requests use `https://api.are.na` as the base URL. Responses are JSON with consistent structure including hypermedia links for resource discovery.\n\n## Authentication\n\nMost endpoints work without authentication but provide additional access when authenticated. Use the standard `Authorization` header:\n\n```\nAuthorization: Bearer YOUR_TOKEN\n```\n\n**Supported Token Types:**\n- **OAuth2 Access Token**: Obtained via [OAuth2 flow](https://www.are.na/developers/explore/authentication) (supports PKCE)\n- **Personal Access Token**: From [are.na/settings/oauth](https://www.are.na/settings/personal-access-tokens)\n\nFor OAuth2, note that the authorization endpoint is hosted on the main site (`www.are.na/oauth/authorize`),\nwhile the token endpoint is on the API (`api.are.na/v3/oauth/token`). The authorization endpoint supports\n`read` and `write` scopes (defaulting to `read` if omitted). A typical authorization URL looks like:\n\n```\nhttps://www.are.na/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&response_type=code&scope=read\n```\n\n**Authentication Levels:**\n- **Public**: No authentication needed (e.g., `/v3/ping`)\n- **Optional**: Works unauthenticated but respects permissions when authenticated\n- **Required**: Returns `401 Unauthorized` without valid token (e.g., `/v3/me`)\n\n## Scopes\n\nOAuth2 tokens and personal access tokens support the following scopes:\n\n| Scope | Description |\n|-------|-------------|\n| `read` | Read-only access (default) — write operations will return `403 Forbidden` |\n| `write` | Full read and write access |\n\nTokens default to `read` scope. Request `write` scope when creating a token or authorizing an OAuth2 application to enable write access. Read-only tokens can view all resources the user has access to but cannot create, update, or delete anything.\n\n## Rate Limiting\n\n**Acceptable Use**: This API is intended for building applications that integrate with Are.na, not for scraping or bulk data collection. Automated crawling, systematic downloading of content, or any form of structured data harvesting is prohibited. If you need bulk access to data for research or other purposes, please [contact us](mailto:help@are.na) to discuss your use case.\n\nRate limits are enforced per-minute based on your tier:\n\n| Tier | Requests/Minute |\n|------|-----------------|\n| Guest (unauthenticated) | 30 |\n| Free | 120 |\n| Premium | 300 |\n| Supporter/Lifetime | 600 |\n\n**The limit is global across the entire API — it is not per-endpoint.** Authenticated requests are counted per account; unauthenticated requests are counted per IP + User-Agent.\n\nWindows are fixed and aligned to the clock minute: your counter resets at the top of every minute, and `X-RateLimit-Reset` is the Unix timestamp of that reset.\n\n**Rate Limit Headers** (included in every response):\n- `X-RateLimit-Limit`: Your tier's per-minute limit\n- `X-RateLimit-Remaining`: Requests left in the current window\n- `X-RateLimit-Tier`: Current tier (guest/free/premium/supporter)\n- `X-RateLimit-Window`: Time window in seconds (always 60)\n- `X-RateLimit-Reset`: Unix timestamp when the limit resets\n\nWhen you exceed limits, you'll receive a `429 Too Many Requests` response with upgrade recommendations and retry timing.\n\n## Request Validation\n\nAll parameters are validated against this OpenAPI specification. Invalid requests return `400 Bad Request` with detailed error messages.\n\n## Error Responses\n\nErrors use standard HTTP status codes with JSON bodies:\n\n```json\n{\n \"error\": \"Not Found\",\n \"code\": 404,\n \"details\": {\n \"message\": \"The resource you requested does not exist\"\n }\n}\n```\n\nCommon status codes:\n- `400`: Invalid request parameters\n- `401`: Authentication required or invalid\n- `403`: Insufficient permissions\n- `404`: Resource not found\n- `408`: Request took too long to process\n- `422`: Well-formed request that could not be processed\n- `429`: Rate limit exceeded\n\n## Pagination\n\nList endpoints return paginated results. Use these query parameters:\n- `page`: Page number (default: 1)\n- `per`: Items per page (default: 24, max: 100)\n\nResponses include a `meta` object with pagination info:\n\n```json\n{\n \"data\": [...],\n \"meta\": {\n \"current_page\": 1,\n \"per_page\": 25,\n \"total_pages\": 5,\n \"total_count\": 120,\n \"next_page\": 2,\n \"prev_page\": null,\n \"has_more_pages\": true\n }\n}\n```\n\n## Best Practices\n\nAre.na channels and users can contain thousands of items. The following guidelines will help you build responsive, well-behaved integrations.\n\n### Paginate, Don't Enumerate\n\nNever try to load an entire channel or user's content in one pass. Use `page` and `per` parameters to fetch only the data you need right now. Check `meta.has_more_pages` and load subsequent pages on demand rather than in a loop.\n\n**Do:**\n- Fetch the first page and display it immediately\n- Load more pages as the user scrolls or explicitly requests them\n\n**Don't:**\n- Loop through all pages at startup to \"build a complete picture\"\n- Set `per=100` and iterate until `has_more_pages` is `false`\n\n### Use HTTP Caching\n\nAll responses include `Cache-Control` and `ETag` headers. Unauthenticated responses are `max-age=300, public` — your client can reuse them for 5 minutes without any network request. Authenticated responses are `private, no-cache` — your client may store them but must revalidate before each reuse.\n\nTo revalidate, send the `ETag` value from a previous response as `If-None-Match`:\n\n```\nGET /v3/channels/123\nIf-None-Match: \"abc123\"\n```\n\nIf the resource hasn't changed, the API returns `304 Not Modified` with no body, saving bandwidth. Single-resource endpoints (e.g., `/v3/channels/:id`, `/v3/blocks/:id`) also skip server-side work entirely on a match. Note that 304 responses still count against your rate limit.\n\n### Fetch Summaries Before Details\n\nStart with lightweight list endpoints (e.g., a channel's first page of contents) before drilling into individual blocks or nested resources. Avoid eagerly resolving every connection or nested object — fetch details only when a user explicitly needs them.\n\n### Respect Rate Limits Gracefully\n\nMonitor the `X-RateLimit-Limit` and `X-RateLimit-Reset` headers. If you receive a `429` response, wait until the reset window before retrying — don't retry in a tight loop. Consider adding exponential backoff and a short delay between sequential requests (e.g., 200–500ms) when making multiple calls.\n\n### Design for Partial Data\n\nChannels can have tens of thousands of connections. Your application should function well with just the first page of results, not require the full dataset upfront. Display what you have and offer the user a way to load more.\n\n### Build Statically When Possible\n\nIf you're building a website that displays Are.na content, prefer static site generation (fetching data at build time) over live API requests on every page view. This eliminates rate limit concerns for your visitors and makes your site faster and more resilient. If you need fresher data, cache responses server-side with a reasonable TTL rather than proxying every request through to the API.\n\n### Avoid Write-Heavy Loops\n\nIf you're creating or connecting multiple blocks, batch your logic and add delays between write operations. Rapid-fire mutations can hit rate limits quickly and may trigger abuse detection.\n" + "description": "The Are.na REST API provides programmatic access to the Are.na platform - a tool for connecting ideas and building knowledge together.\n\n## Getting Started\n\nAll API requests use `https://api.are.na` as the base URL. Responses are JSON with consistent structure including hypermedia links for resource discovery.\n\n## Authentication\n\nMost endpoints work without authentication but provide additional access when authenticated. Use the standard `Authorization` header:\n\n```\nAuthorization: Bearer YOUR_TOKEN\n```\n\n**Supported Token Types:**\n- **OAuth2 Access Token**: Obtained via [OAuth2 flow](https://www.are.na/developers/explore/authentication) (supports PKCE)\n- **Personal Access Token**: From [are.na/settings/oauth](https://www.are.na/settings/personal-access-tokens)\n\nFor OAuth2, note that the authorization endpoint is hosted on the main site (`www.are.na/oauth/authorize`),\nwhile the token endpoint is on the API (`api.are.na/v3/oauth/token`). The authorization endpoint supports\n`read` and `write` scopes (defaulting to `read` if omitted). A typical authorization URL looks like:\n\n```\nhttps://www.are.na/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&response_type=code&scope=read\n```\n\n**Authentication Levels:**\n- **Public**: No authentication needed (e.g., `/v3/ping`)\n- **Optional**: Works unauthenticated but respects permissions when authenticated\n- **Required**: Returns `401 Unauthorized` without valid token (e.g., `/v3/me`)\n\n## Scopes\n\nOAuth2 tokens and personal access tokens support the following scopes:\n\n| Scope | Description |\n|-------|-------------|\n| `read` | Read-only access (default) — write operations will return `403 Forbidden` |\n| `write` | Full read and write access |\n\nTokens default to `read` scope. Request `write` scope when creating a token or authorizing an OAuth2 application to enable write access. Read-only tokens can view all resources the user has access to but cannot create, update, or delete anything.\n\n## Rate Limiting\n\n**Acceptable Use**: This API is intended for building applications that integrate with Are.na, not for scraping or bulk data collection. Automated crawling, systematic downloading of content, or any form of structured data harvesting is prohibited. If you need bulk access to data for research or other purposes, please [contact us](mailto:help@are.na) to discuss your use case.\n\nRate limits are enforced per-minute based on your tier:\n\n| Tier | Requests/Minute |\n|------|-----------------|\n| Guest (unauthenticated) | 30 |\n| Free | 120 |\n| Premium | 300 |\n| Supporter/Lifetime | 600 |\n\n**The limit is global across the entire API — it is not per-endpoint.** Authenticated requests are counted per account; unauthenticated requests are counted per IP.\n\nReads and writes have separate budgets. Reads keep a per-minute budget. Writes use both a per-minute burst budget and a per-hour sustained budget.\n\n**Rate Limit Headers** (included in every response):\n- `X-RateLimit-Limit`: The currently binding bucket's limit\n- `X-RateLimit-Remaining`: Units left in the currently binding bucket\n- `X-RateLimit-Tier`: Current tier (guest/free/premium/supporter)\n- `X-RateLimit-Window`: Binding bucket window in seconds\n- `X-RateLimit-Reset`: Unix timestamp when the binding bucket resets\n- `X-RateLimit-Bucket`: Binding bucket name (`read-minute`, `write-minute`, `write-hour`)\n- `X-RateLimit-Cost`: Cost of the current request in budget units\n- `X-RateLimit-Policy`: Budget policy for this request class\n\nWhen you exceed limits, you'll receive a `429 Too Many Requests` response with retry timing and an upgrade URL when available.\n\n## Request Validation\n\nAll parameters are validated against this OpenAPI specification. Invalid requests return `400 Bad Request` with detailed error messages.\n\n## Error Responses\n\nErrors use standard HTTP status codes with JSON bodies:\n\n```json\n{\n \"error\": \"Not Found\",\n \"code\": 404,\n \"details\": {\n \"message\": \"The resource you requested does not exist\"\n }\n}\n```\n\nCommon status codes:\n- `400`: Invalid request parameters\n- `401`: Authentication required or invalid\n- `403`: Insufficient permissions\n- `404`: Resource not found\n- `408`: Request took too long to process\n- `422`: Well-formed request that could not be processed\n- `429`: Rate limit exceeded\n\n## Pagination\n\nList endpoints return paginated results. Use these query parameters:\n- `page`: Page number (default: 1)\n- `per`: Items per page (default: 24, max: 100)\n\nResponses include a `meta` object with pagination info:\n\n```json\n{\n \"data\": [...],\n \"meta\": {\n \"current_page\": 1,\n \"per_page\": 25,\n \"total_pages\": 5,\n \"total_count\": 120,\n \"next_page\": 2,\n \"prev_page\": null,\n \"has_more_pages\": true\n }\n}\n```\n\n## Best Practices\n\nAre.na channels and users can contain thousands of items. The following guidelines will help you build responsive, well-behaved integrations.\n\n### Paginate, Don't Enumerate\n\nNever try to load an entire channel or user's content in one pass. Use `page` and `per` parameters to fetch only the data you need right now. Check `meta.has_more_pages` and load subsequent pages on demand rather than in a loop.\n\n**Do:**\n- Fetch the first page and display it immediately\n- Load more pages as the user scrolls or explicitly requests them\n\n**Don't:**\n- Loop through all pages at startup to \"build a complete picture\"\n- Set `per=100` and iterate until `has_more_pages` is `false`\n\n### Use HTTP Caching\n\nAll responses include `Cache-Control` and `ETag` headers. Unauthenticated responses are `max-age=300, public` — your client can reuse them for 5 minutes without any network request. Authenticated responses are `private, no-cache` — your client may store them but must revalidate before each reuse.\n\nTo revalidate, send the `ETag` value from a previous response as `If-None-Match`:\n\n```\nGET /v3/channels/123\nIf-None-Match: \"abc123\"\n```\n\nIf the resource hasn't changed, the API returns `304 Not Modified` with no body, saving bandwidth. Single-resource endpoints (e.g., `/v3/channels/:id`, `/v3/blocks/:id`) also skip server-side work entirely on a match. Note that 304 responses still count against your rate limit.\n\n### Fetch Summaries Before Details\n\nStart with lightweight list endpoints (e.g., a channel's first page of contents) before drilling into individual blocks or nested resources. Avoid eagerly resolving every connection or nested object — fetch details only when a user explicitly needs them.\n\n### Respect Rate Limits Gracefully\n\nMonitor the `X-RateLimit-Limit` and `X-RateLimit-Reset` headers. If you receive a `429` response, wait until the reset window before retrying — don't retry in a tight loop. Consider adding exponential backoff and a short delay between sequential requests (e.g., 200–500ms) when making multiple calls.\n\n### Design for Partial Data\n\nChannels can have tens of thousands of connections. Your application should function well with just the first page of results, not require the full dataset upfront. Display what you have and offer the user a way to load more.\n\n### Build Statically When Possible\n\nIf you're building a website that displays Are.na content, prefer static site generation (fetching data at build time) over live API requests on every page view. This eliminates rate limit concerns for your visitors and makes your site faster and more resilient. If you need fresher data, cache responses server-side with a reasonable TTL rather than proxying every request through to the API.\n\n### Avoid Write-Heavy Loops\n\nIf you're creating or connecting multiple blocks, batch your logic and add delays between write operations. Rapid-fire mutations can hit rate limits quickly and may trigger abuse detection.\n" }, "servers": [ { @@ -283,21 +283,21 @@ "description": "Rate limit exceeded", "headers": { "Retry-After": { - "description": "Seconds until the current rate limit window resets", + "description": "Seconds until the binding rate limit bucket resets", "schema": { "type": "string" }, "example": "37" }, "X-RateLimit-Limit": { - "description": "Request limit per minute", + "description": "Binding bucket limit", "schema": { "type": "string" }, "example": "30" }, "X-RateLimit-Remaining": { - "description": "Requests left in the current window", + "description": "Units left in the binding bucket", "schema": { "type": "string" }, @@ -311,18 +311,39 @@ "example": "guest" }, "X-RateLimit-Window": { - "description": "Time window in seconds", + "description": "Binding bucket window in seconds", "schema": { "type": "string" }, "example": "60" }, "X-RateLimit-Reset": { - "description": "Unix timestamp when the current window resets", + "description": "Unix timestamp when the binding bucket resets", "schema": { "type": "string" }, "example": "1700000100" + }, + "X-RateLimit-Bucket": { + "description": "Binding bucket name", + "schema": { + "type": "string" + }, + "example": "read-minute" + }, + "X-RateLimit-Cost": { + "description": "Cost of the current request in budget units", + "schema": { + "type": "string" + }, + "example": "1" + }, + "X-RateLimit-Policy": { + "description": "Budget policy for this request class", + "schema": { + "type": "string" + }, + "example": "30;w=60" } }, "content": { @@ -402,12 +423,12 @@ "limit": { "type": "integer", "example": 30, - "description": "Request limit per minute for this tier" + "description": "Binding request limit for this rejection" }, "limit_window": { "type": "string", "example": "1 minute", - "description": "Time window for rate limits" + "description": "Time window for the binding rate limit bucket" }, "retry_after": { "type": "integer", @@ -430,6 +451,29 @@ "supporter": 600 } }, + "detailed_limits": { + "type": "object", + "example": { + "guest": { + "read_minute": 30 + }, + "free": { + "read_minute": 120, + "write_minute": 30, + "write_hour": 300 + }, + "premium": { + "read_minute": 300, + "write_minute": 60, + "write_hour": 1500 + }, + "supporter": { + "read_minute": 600, + "write_minute": 120, + "write_hour": 4000 + } + } + }, "upgrade_path": { "type": "object", "properties": { @@ -565,6 +609,12 @@ "description": "URL-safe identifier (use this in API paths)", "example": "john-doe" }, + "username": { + "type": ["string", "null"], + "readOnly": true, + "description": "User's username (either automatically generated or customized)", + "example": "john-doe" + }, "avatar": { "type": ["string", "null"], "format": "uri", diff --git a/src/generated/schema.d.ts b/src/generated/schema.d.ts index fcd7572..4266c24 100644 --- a/src/generated/schema.d.ts +++ b/src/generated/schema.d.ts @@ -1139,12 +1139,12 @@ export interface components { */ tier: components["schemas"]["UserTier"]; /** - * @description Request limit per minute for this tier + * @description Binding request limit for this rejection * @example 30 */ limit: number; /** - * @description Time window for rate limits + * @description Time window for the binding rate limit bucket * @example 1 minute */ limit_window?: string; @@ -1165,6 +1165,29 @@ export interface components { * } */ limits?: Record; + /** + * @example { + * "guest": { + * "read_minute": 30 + * }, + * "free": { + * "read_minute": 120, + * "write_minute": 30, + * "write_hour": 300 + * }, + * "premium": { + * "read_minute": 300, + * "write_minute": 60, + * "write_hour": 1500 + * }, + * "supporter": { + * "read_minute": 600, + * "write_minute": 120, + * "write_hour": 4000 + * } + * } + */ + detailed_limits?: Record; upgrade_path?: { /** @example Guest (30 req/min) */ current?: string; @@ -1262,6 +1285,11 @@ export interface components { * @example john-doe */ slug: string; + /** + * @description User's username (either automatically generated or customized) + * @example john-doe + */ + readonly username?: string | null; /** * Format: uri * @description URL to user's avatar image @@ -3047,17 +3075,17 @@ export interface components { RateLimitResponse: { headers: { /** - * @description Seconds until the current rate limit window resets + * @description Seconds until the binding rate limit bucket resets * @example 37 */ "Retry-After"?: string; /** - * @description Request limit per minute + * @description Binding bucket limit * @example 30 */ "X-RateLimit-Limit"?: string; /** - * @description Requests left in the current window + * @description Units left in the binding bucket * @example 0 */ "X-RateLimit-Remaining"?: string; @@ -3067,15 +3095,30 @@ export interface components { */ "X-RateLimit-Tier"?: string; /** - * @description Time window in seconds + * @description Binding bucket window in seconds * @example 60 */ "X-RateLimit-Window"?: string; /** - * @description Unix timestamp when the current window resets + * @description Unix timestamp when the binding bucket resets * @example 1700000100 */ "X-RateLimit-Reset"?: string; + /** + * @description Binding bucket name + * @example read-minute + */ + "X-RateLimit-Bucket"?: string; + /** + * @description Cost of the current request in budget units + * @example 1 + */ + "X-RateLimit-Cost"?: string; + /** + * @description Budget policy for this request class + * @example 30;w=60 + */ + "X-RateLimit-Policy"?: string; [name: string]: unknown; }; content: { diff --git a/src/generated/tools.ts b/src/generated/tools.ts index dc1211e..3017427 100644 --- a/src/generated/tools.ts +++ b/src/generated/tools.ts @@ -68,7 +68,7 @@ export function registerGeneratedTools(server: McpServer): void { "Optional cover image URL to use as the visual preview for Link, Attachment, and Embed blocks (overrides the auto-generated screenshot/thumbnail). Ignored for Image and Text blocks.", ), metadata: z - .record(z.unknown()) + .record(z.union([z.string().max(2000), z.number(), z.boolean()])) .optional() .describe("Custom key-value metadata to set on the new block."), channel_ids: z @@ -97,7 +97,9 @@ export function registerGeneratedTools(server: McpServer): void { "Position to insert at within this channel (1-indexed).", ), metadata: z - .record(z.unknown()) + .record( + z.union([z.string().max(2000), z.number(), z.boolean()]), + ) .optional() .describe("Connection metadata for this specific connection."), }), @@ -178,7 +180,9 @@ export function registerGeneratedTools(server: McpServer): void { "Optional cover image URL to use as the visual preview for Link, Attachment, and Embed blocks (overrides the auto-generated screenshot/thumbnail). Ignored for Image and Text blocks.", ), metadata: z - .record(z.unknown()) + .record( + z.union([z.string().max(2000), z.number(), z.boolean()]), + ) .optional() .describe("Custom key-value metadata to set on the new block."), }), @@ -268,7 +272,9 @@ export function registerGeneratedTools(server: McpServer): void { .optional() .describe("Alt text for images (for Image blocks)"), metadata: z - .record(z.unknown()) + .record( + z.union([z.string().max(2000), z.number(), z.boolean(), z.null()]), + ) .optional() .describe( "Custom key-value metadata. Uses merge semantics: new keys are added,\nexisting keys are updated, keys set to null are removed.\n", @@ -305,11 +311,14 @@ export function registerGeneratedTools(server: McpServer): void { page: z .number() .int() + .min(1) .optional() .describe("Page number for pagination"), per: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of items per page (max 100)"), sort: z @@ -354,11 +363,14 @@ export function registerGeneratedTools(server: McpServer): void { page: z .number() .int() + .min(1) .optional() .describe("Page number for pagination"), per: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of items per page (max 100)"), sort: z @@ -453,7 +465,7 @@ export function registerGeneratedTools(server: McpServer): void { "User or Group to own the channel. The authenticated user must be able to assign authorship\nto the owner: themselves, or a group they belong to. If not provided, the channel is owned\nby the authenticated user.\n", ), metadata: z - .record(z.unknown()) + .record(z.union([z.string().max(2000), z.number(), z.boolean()])) .optional() .describe("Custom key-value metadata to set on the new channel."), }, @@ -528,7 +540,9 @@ export function registerGeneratedTools(server: McpServer): void { "User or Group to own the channel. The authenticated user must be able to assign authorship\nto the owner: themselves, or a group they belong to.\n", ), metadata: z - .record(z.unknown()) + .record( + z.union([z.string().max(2000), z.number(), z.boolean(), z.null()]), + ) .optional() .describe( "Custom key-value metadata. Uses merge semantics: new keys are added,\nexisting keys are updated, keys set to null are removed.\n", @@ -615,7 +629,9 @@ export function registerGeneratedTools(server: McpServer): void { "Position to insert at within this channel (1-indexed).", ), metadata: z - .record(z.unknown()) + .record( + z.union([z.string().max(2000), z.number(), z.boolean()]), + ) .optional() .describe("Connection metadata for this specific connection."), }), @@ -674,7 +690,9 @@ export function registerGeneratedTools(server: McpServer): void { inputSchema: { id: z.number().int().describe("Resource ID"), metadata: z - .record(z.unknown()) + .record( + z.union([z.string().max(2000), z.number(), z.boolean(), z.null()]), + ) .optional() .describe( "Custom key-value metadata. Uses merge semantics: new keys are added,\nexisting keys are updated, keys set to null are removed.\n", @@ -766,11 +784,14 @@ export function registerGeneratedTools(server: McpServer): void { page: z .number() .int() + .min(1) .optional() .describe("Page number for pagination"), per: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of items per page (max 100)"), sort: z @@ -822,11 +843,14 @@ export function registerGeneratedTools(server: McpServer): void { page: z .number() .int() + .min(1) .optional() .describe("Page number for pagination"), per: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of items per page (max 100)"), sort: z @@ -866,11 +890,14 @@ export function registerGeneratedTools(server: McpServer): void { page: z .number() .int() + .min(1) .optional() .describe("Page number for pagination"), per: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of items per page (max 100)"), sort: z @@ -925,6 +952,8 @@ export function registerGeneratedTools(server: McpServer): void { limit: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of feed items to return (max 100)"), next: z @@ -967,6 +996,8 @@ export function registerGeneratedTools(server: McpServer): void { limit: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of feed items to return (max 100)"), next: z @@ -1081,11 +1112,14 @@ export function registerGeneratedTools(server: McpServer): void { page: z .number() .int() + .min(1) .optional() .describe("Page number for pagination"), per: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of items per page (max 100)"), sort: z @@ -1140,11 +1174,14 @@ export function registerGeneratedTools(server: McpServer): void { page: z .number() .int() + .min(1) .optional() .describe("Page number for pagination"), per: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of items per page (max 100)"), sort: z @@ -1177,11 +1214,14 @@ export function registerGeneratedTools(server: McpServer): void { page: z .number() .int() + .min(1) .optional() .describe("Page number for pagination"), per: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of items per page (max 100)"), sort: z @@ -1223,11 +1263,14 @@ export function registerGeneratedTools(server: McpServer): void { page: z .number() .int() + .min(1) .optional() .describe("Page number for pagination"), per: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of items per page (max 100)"), sort: z @@ -1392,11 +1435,14 @@ export function registerGeneratedTools(server: McpServer): void { page: z .number() .int() + .min(1) .optional() .describe("Page number for pagination"), per: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of items per page (max 100)"), }, @@ -1538,11 +1584,14 @@ export function registerGeneratedTools(server: McpServer): void { page: z .number() .int() + .min(1) .optional() .describe("Page number for pagination"), per: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of items per page (max 100)"), }, @@ -1701,11 +1750,14 @@ export function registerGeneratedTools(server: McpServer): void { page: z .number() .int() + .min(1) .optional() .describe("Page number for pagination"), per: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of items per page (max 100)"), sort: z @@ -1760,11 +1812,14 @@ export function registerGeneratedTools(server: McpServer): void { page: z .number() .int() + .min(1) .optional() .describe("Page number for pagination"), per: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of items per page (max 100)"), sort: z @@ -1946,11 +2001,14 @@ export function registerGeneratedTools(server: McpServer): void { page: z .number() .int() + .min(1) .optional() .describe("Page number for pagination"), per: z .number() .int() + .min(1) + .max(100) .optional() .describe("Number of items per page (max 100)"), }, diff --git a/src/lib/json-schema-to-zod.ts b/src/lib/json-schema-to-zod.ts index 701076e..bb44119 100644 --- a/src/lib/json-schema-to-zod.ts +++ b/src/lib/json-schema-to-zod.ts @@ -19,6 +19,8 @@ interface JsonSchema { maximum?: number; minItems?: number; maxItems?: number; + minLength?: number; + maxLength?: number; additionalProperties?: boolean | JsonSchema; } @@ -79,11 +81,21 @@ export function jsonSchemaToZodString( const isNullable = types.includes("null"); const primaryType = nonNullTypes[0]; + if (nonNullTypes.length === 0 && isNullable) return "z.null()"; + if (nonNullTypes.length > 1) { + const variants = nonNullTypes.map((type) => + jsonSchemaToZodString({ ...schema, type }, options), + ); + return `z.union([${variants.join(", ")}])${isNullable ? ".nullable()" : ""}`; + } + let base: string; switch (primaryType) { case "string": base = "z.string()"; + if (schema.minLength !== undefined) base += `.min(${schema.minLength})`; + if (schema.maxLength !== undefined) base += `.max(${schema.maxLength})`; break; case "integer": base = "z.number().int()"; @@ -120,7 +132,11 @@ export function jsonSchemaToZodString( ); base = `z.object({ ${props.join(", ")} })`; } else { - base = "z.record(z.unknown())"; + const valueSchema = + typeof schema.additionalProperties === "object" + ? jsonSchemaToZodString(schema.additionalProperties, options) + : "z.unknown()"; + base = `z.record(${valueSchema})`; } break; } @@ -128,6 +144,11 @@ export function jsonSchemaToZodString( base = "z.unknown()"; } + if (primaryType === "integer" || primaryType === "number") { + if (schema.minimum !== undefined) base += `.min(${schema.minimum})`; + if (schema.maximum !== undefined) base += `.max(${schema.maximum})`; + } + if (isNullable) { base += ".nullable()"; } diff --git a/src/lib/tool-helpers.ts b/src/lib/tool-helpers.ts index e0b6c5e..e64ee5e 100644 --- a/src/lib/tool-helpers.ts +++ b/src/lib/tool-helpers.ts @@ -3,7 +3,7 @@ import { createArenaClient, type ArenaClient } from "../client"; export function textResult(data: unknown): CallToolResult { return { - content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + content: [{ type: "text", text: JSON.stringify(data, null, 2) ?? "null" }], }; } diff --git a/src/tools/custom/channel-digest.ts b/src/tools/custom/channel-digest.ts index 46ece1d..048cc37 100644 --- a/src/tools/custom/channel-digest.ts +++ b/src/tools/custom/channel-digest.ts @@ -6,41 +6,6 @@ import { errorResult, } from "../../lib/tool-helpers"; -interface Block { - id: number; - class?: string; - title?: string | null; - created_at?: string; - updated_at?: string; - user?: { slug?: string } | null; - connection?: { created_at?: string; user?: { slug?: string } } | null; -} - -interface ChannelMeta { - id: number; - title: string; - slug: string; - visibility: string; - description?: { text?: string } | null; - created_at: string; - updated_at: string; - owner: { slug?: string; name?: string; class?: string }; - counts: { - blocks: number; - channels: number; - contents: number; - collaborators: number; - }; - collaborators?: Array<{ slug?: string; name?: string; class?: string }>; -} - -interface PaginationMeta { - current_page: number; - total_pages: number; - total_count: number; - has_more_pages: boolean; -} - export function registerChannelDigest(server: McpServer): void { server.registerTool( "channelDigest", @@ -64,12 +29,12 @@ export function registerChannelDigest(server: McpServer): void { return withArenaClient(extra, async (client) => { const { data: channel, error: channelError } = await client.GET( "/v3/channels/{id}", - { params: { path: { id: args.channel_id } } } as any, + { params: { path: { id: args.channel_id } } }, ); if (channelError) return errorResult(channelError); - const meta = channel as unknown as ChannelMeta; + const meta = channel!; const sampleSize = args.sample_size ?? 100; const { data: contentsData, error: contentsError } = await client.GET( @@ -79,34 +44,30 @@ export function registerChannelDigest(server: McpServer): void { path: { id: args.channel_id }, query: { per: sampleSize, sort: "created_at_desc" }, }, - } as any, + }, ); if (contentsError) return errorResult(contentsError); - const contents = contentsData as unknown as { - data?: Block[]; - meta?: PaginationMeta; - }; - const items = contents?.data ?? []; + const items = contentsData?.data ?? []; const typeCounts: Record = {}; const contributors = new Set(); for (const item of items) { - const cls = item.class ?? "Unknown"; - typeCounts[cls] = (typeCounts[cls] ?? 0) + 1; + const type = item.type; + typeCounts[type] = (typeCounts[type] ?? 0) + 1; - const who = item.connection?.user?.slug ?? item.user?.slug; + const who = item.connection?.connected_by?.slug; if (who) contributors.add(who); } const recentItems = items.slice(0, 5).map((item) => ({ id: item.id, - type: item.class, + type: item.type, title: item.title ?? undefined, - added: item.connection?.created_at ?? item.created_at, - added_by: item.connection?.user?.slug ?? item.user?.slug, + added: item.connection?.connected_at ?? item.created_at, + added_by: item.connection?.connected_by?.slug, })); const sampled = items.length; @@ -119,20 +80,20 @@ export function registerChannelDigest(server: McpServer): void { title: meta.title, slug: meta.slug, visibility: meta.visibility, - description: meta.description?.text ?? null, + description: meta.description?.plain ?? null, created_at: meta.created_at, updated_at: meta.updated_at, }, owner: { slug: meta.owner.slug, name: meta.owner.name, - type: meta.owner.class, + type: meta.owner.type, }, counts: meta.counts, collaborators: meta.collaborators?.map((c) => ({ slug: c.slug, name: c.name, - type: c.class, + type: c.type, })), type_breakdown: { ...(isSampled diff --git a/src/tools/custom/search-and-connect.ts b/src/tools/custom/search-and-connect.ts index 668a019..ffbc5b2 100644 --- a/src/tools/custom/search-and-connect.ts +++ b/src/tools/custom/search-and-connect.ts @@ -67,27 +67,24 @@ export function registerSearchAndConnect(server: McpServer): void { if (searchError) return errorResult(searchError); - const items = - (searchData as { data?: Array<{ id: number; class?: string }> }) - ?.data ?? []; + const items = searchData?.data ?? []; const connected: Array<{ id: number; type: string }> = []; - const skipped: Array<{ id: number; class?: string }> = []; + const skipped: Array<{ id: number; type: string }> = []; const errors: string[] = []; for (const item of items) { - if (!item.class || !CONNECTABLE_TYPES.has(item.class)) { - skipped.push({ id: item.id, class: item.class }); + if (!item.type || !CONNECTABLE_TYPES.has(item.type)) { + skipped.push({ id: item.id, type: item.type }); continue; } - const connectableType = - item.class === "Channel" ? "Channel" : "Block"; + const connectableType = item.type === "Channel" ? "Channel" : "Block"; const { error: connectError } = await client.POST("/v3/connections", { body: { connectable_id: item.id, - connectable_type: connectableType as "Block" | "Channel", + connectable_type: connectableType, channel_ids: [args.target_channel_id], }, });