Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
31 changes: 23 additions & 8 deletions scripts/generate-tools.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, unknown>;
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<string, Record<string, Operation>>;

const tools: ToolDef[] = [];
Expand Down Expand Up @@ -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}`);
Expand Down
217 changes: 217 additions & 0 deletions src/__tests__/api-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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: "<p>Plain <b>description</b></p>",
},
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();
},
);
});
Loading
Loading