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
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,24 @@ When the Are.na API spec changes:
yarn generate
```

This fetches the latest spec and regenerates TypeScript types and tool registrations. To regenerate from the checked-in spec without fetching it again:
This fetches the latest spec and regenerates TypeScript types, tool registrations, and output schemas. 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.

## Tool results

Every tool advertises an `outputSchema` and returns successful results in the same shape in `structuredContent` and JSON text:

```json
{ "result": { "id": 123, "type": "Text" } }
```

`result` contains the complete API response (including `data` and `meta` for list endpoints), or `null` for an empty response. The example above is abbreviated. Text consumers should read the payload under `result`; earlier versions returned it directly.

Generated output schemas preserve the OpenAPI success-response contracts and include the referenced components as local `$defs`. The two custom tools have explicit schemas. Responses are validated before being reported as successful; API errors retain `isError: true` and are not validated against a success schema. An output-validation failure can occur after a write has completed, so it should not trigger an automatic write retry.

The server uses the MCP SDK's low-level tool handlers to publish JSON Schemas directly, with Zod for input parsing and a Workers-compatible JSON Schema validator for outputs.
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,20 @@
"dev:http": "wrangler dev src/transports/http.ts",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit",
"test": "vitest run --exclude 'dist/**'",
"test": "node --test .github/scripts/review-policy.test.cjs && vitest run --exclude 'dist/**' --exclude '.github/**'",
"format": "prettier --write .",
"prepare": "husky"
},
"lint-staged": {
"*.{ts,js,json,md}": "prettier --write"
},
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
"@modelcontextprotocol/sdk": "^1.27.0",
"hono": "^4.0.0",
"openapi-fetch": "^0.17.0",
"zod": "^3.25.0"
"zod": "^3.25.0",
"zod-to-json-schema": "^3.25.1"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.0.0",
Expand Down
35 changes: 25 additions & 10 deletions scripts/generate-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { readFileSync, writeFileSync } from "node:fs";
import { execFileSync, execSync } from "node:child_process";
import { jsonSchemaToZodString } from "../src/lib/json-schema-to-zod";

import { generateOutputSchemas } from "./output-schemas";

const OUTPUTS_PATH = "src/generated/output-schemas.ts";

const SPEC_URL = "https://api.are.na/v3/openapi.json";
const SPEC_PATH = "src/generated/openapi.json";
const TYPES_PATH = "src/generated/schema.d.ts";
Expand All @@ -24,6 +28,7 @@ interface Param {

interface Operation {
operationId: string;
responses?: Record<string, Record<string, unknown>>;
summary?: string;
description?: string;
parameters?: Array<Param | { $ref: string }>;
Expand Down Expand Up @@ -298,7 +303,7 @@ function generateHandler(tool: ToolDef): string {
return withArenaClient(extra, async (client) => {
const { data, error } = await client.${tool.method}(${pathType}${optionsStr});
if (error) return errorResult(error);
return textResult(data);
return successResult(data);
}).catch((err) => errorResult(err.message));
}`;
}
Expand Down Expand Up @@ -333,13 +338,17 @@ function main() {
const paths = spec.paths as Record<string, Record<string, Operation>>;

const tools: ToolDef[] = [];
const operations: Operation[] = [];

for (const [path, methods] of Object.entries(paths)) {
for (const httpMethod of HTTP_METHODS) {
const op = methods[httpMethod];
if (!op) continue;
const tool = buildToolDef(path, httpMethod, op, spec);
if (tool) tools.push(tool);
if (tool) {
tools.push(tool);
operations.push(op);
}
}
}

Expand All @@ -348,15 +357,14 @@ function main() {
`// Auto-generated by scripts/generate-tools.ts — do not edit manually`,
);
lines.push(`import { z } from "zod";`);
lines.push(`import type { ToolRegistry } from "../lib/tool-registry";`);
lines.push(
`import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";`,
);
lines.push(
`import { withArenaClient, textResult, errorResult } from "../lib/tool-helpers";`,
`import { withArenaClient, successResult, errorResult } from "../lib/tool-helpers";`,
);
lines.push(`import { outputSchemas } from "./output-schemas";`);
lines.push(``);
lines.push(
`export function registerGeneratedTools(server: McpServer): void {`,
`export function registerGeneratedTools(server: ToolRegistry): void {`,
);

for (const tool of tools) {
Expand All @@ -374,6 +382,9 @@ function main() {
lines.push(` {`);
lines.push(` description: ${JSON.stringify(desc)},`);
lines.push(` inputSchema: ${schemaInner},`);
lines.push(
` outputSchema: outputSchemas[${JSON.stringify(tool.operationId)}],`,
);
lines.push(` },`);
lines.push(generateHandler(tool));
lines.push(` );`);
Expand All @@ -382,11 +393,15 @@ function main() {
lines.push(`}`);
lines.push(``);

writeFileSync(OUTPUTS_PATH, generateOutputSchemas(spec, operations));
writeFileSync(TOOLS_PATH, lines.join("\n"));
writeFileSync(SPEC_PATH, `${JSON.stringify(spec, null, 2)}\n`);
execSync(`npx prettier --write ${TOOLS_PATH} ${SPEC_PATH} ${TYPES_PATH}`, {
stdio: "inherit",
});
execSync(
`npx prettier --write ${TOOLS_PATH} ${SPEC_PATH} ${TYPES_PATH} ${OUTPUTS_PATH}`,
{
stdio: "inherit",
},
);
console.log(`Generated ${tools.length} tools → ${TOOLS_PATH}`);
}

Expand Down
204 changes: 204 additions & 0 deletions scripts/output-schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { expandTypeArray } from "../src/lib/portable-json-schema";

type Schema = Record<string, unknown>;
export interface ResponseOperation {
operationId: string;
responses?: Record<string, Schema>;
}

function resolvePointer(spec: Schema, ref: string): Schema {
if (!ref.startsWith("#/"))
throw new Error(`External schema reference: ${ref}`);
let value: unknown = spec;
for (const segment of ref.slice(2).split("/")) {
const key = segment.replace(/~1/g, "/").replace(/~0/g, "~");
if (!value || typeof value !== "object" || !(key in value)) {
throw new Error(`Unresolved schema reference: ${ref}`);
}
value = (value as Schema)[key];
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected a schema at ${ref}`);
}
return value as Schema;
}

export function generateOutputSchemas(
spec: Schema,
operations: ResponseOperation[],
): string {
const definitions = new Map<string, Schema>();
const dependencies = new Map<string, Set<string>>();

function copySchema(value: unknown, refs: Set<string>): unknown {
if (Array.isArray(value))
return value.map((item) => copySchema(item, refs));
if (!value || typeof value !== "object") return value;
const result: Schema = {};
for (const [key, entry] of Object.entries(value)) {
// Examples and OpenAPI-only annotations aren't validation rules. Omitting
// examples also avoids treating example data containing "$ref" as schemas.
if (
[
"example",
"examples",
"discriminator",
"xml",
"externalDocs",
].includes(key)
)
continue;
if (key === "$ref") {
if (
typeof entry !== "string" ||
!entry.startsWith("#/components/schemas/")
) {
throw new Error(`Unsupported response schema reference: ${entry}`);
}
const name = entry.slice("#/components/schemas/".length);
refs.add(name);
if (!definitions.has(name)) {
definitions.set(name, {});
const nested = new Set<string>();
dependencies.set(name, nested);
definitions.set(
name,
copySchema(resolvePointer(spec, entry), nested) as Schema,
);
}
result[key] = `#/$defs/${name}`;
} else if (
[
"properties",
"patternProperties",
"$defs",
"dependentSchemas",
].includes(key)
) {
result[key] = Object.fromEntries(
Object.entries(entry as Schema).map(([name, child]) => [
name,
copySchema(child, refs),
]),
);
} else if (
[
"allOf",
"anyOf",
"oneOf",
"prefixItems",
"items",
"additionalProperties",
"not",
"contains",
"if",
"then",
"else",
"propertyNames",
"unevaluatedProperties",
"unevaluatedItems",
].includes(key)
) {
result[key] = copySchema(entry, refs);
} else {
result[key] = entry;
}
}
// OpenAPI sometimes places documentation beside a reference in allOf.
// Lift those annotations so clients don't mistake them for untyped schemas.
if (Array.isArray(result.allOf)) {
const descriptions = result.allOf.filter(
(child) =>
child &&
typeof child === "object" &&
Object.keys(child).length === 1 &&
typeof child.description === "string",
);
const constraints = result.allOf.filter(
(child) => !descriptions.includes(child),
);
if (descriptions.length && constraints.length) {
result.allOf = constraints;
result.description ??= descriptions
.map((child) => child.description)
.join("\n\n");
}
}
return expandTypeArray(result);
}

const outputs = operations.map((operation) => {
const schemas: Schema[] = [];
const refs = new Set<string>();
for (const [status, original] of Object.entries(
operation.responses ?? {},
)) {
if (!/^2\d\d$|^2XX$/.test(status)) continue;
let response = original;
const seen = new Set<string>();
while (typeof response.$ref === "string") {
if (seen.has(response.$ref))
throw new Error(`Cyclic response reference: ${response.$ref}`);
seen.add(response.$ref);
response = resolvePointer(spec, response.$ref);
}
const content = response.content as
| Record<string, { schema?: Schema }>
| undefined;
const schema = content?.["application/json"]?.schema;
if (!schema && status !== "204" && status !== "205") {
throw new Error(
`${operation.operationId} ${status} has no JSON response schema`,
);
}
const copied = copySchema(schema ?? { type: "null" }, refs) as Schema;
if (
!schemas.some((item) => JSON.stringify(item) === JSON.stringify(copied))
)
schemas.push(copied);
}
if (!schemas.length)
throw new Error(
`${operation.operationId} has no successful response schema`,
);
const collect = (name: string) => {
for (const child of dependencies.get(name) ?? []) {
if (!refs.has(child)) {
refs.add(child);
collect(child);
}
}
};
for (const name of refs) collect(name);
return {
name: operation.operationId,
schema: schemas.length === 1 ? schemas[0] : { anyOf: schemas },
refs,
};
});

return [
"// Auto-generated by scripts/generate-tools.ts — do not edit manually",
'import type { OutputSchema } from "../lib/tool-registry";',
`const definitions = ${JSON.stringify(Object.fromEntries(definitions), null, 2)};`,
"export const outputSchemas = {",
...outputs.map(
({ name, schema, refs }) => `${JSON.stringify(name)}: {
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: { result: ${JSON.stringify(schema)} },
required: ["result"],
additionalProperties: false,
$defs: { ${[...refs]
.sort()
.map(
(ref) =>
`${JSON.stringify(ref)}: definitions[${JSON.stringify(ref)}]`,
)
.join(",")} }
},`,
),
"} satisfies Record<string, OutputSchema>;",
"",
].join("\n");
}
Loading
Loading