diff --git a/README.md b/README.md index f20c49e..821bef0 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ readwise reader-create-document --url "https://example.com/article" readwise reader-create-document \ --url "https://example.com" \ --title "My Article" \ + --language "en" \ --tags "reading-list,research" \ --notes "Found via HN" ``` @@ -114,6 +115,7 @@ readwise reader-move-documents --document-ids --location archive readwise reader-bulk-edit-document-metadata --documents '[{"document_id":"","title":"Better Title"}]' readwise reader-bulk-edit-document-metadata --documents '[{"document_id":"","seen":true}]' readwise reader-bulk-edit-document-metadata --documents '[{"document_id":"","notes":"Updated notes"}]' +readwise reader-bulk-edit-document-metadata --documents '[{"document_id":"","language":"es"}]' ``` ### Highlight management diff --git a/src/commands.ts b/src/commands.ts index b126e82..0230691 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -48,6 +48,18 @@ export function optionFlag(name: string, prop: SchemaProperty): string { return `${flag} `; } +export function optionDescription(prop: SchemaProperty, isRequired: boolean): string | undefined { + const parts: string[] = []; + if (prop.description) parts.push(prop.description); + if (isRequired) parts.push("(required)"); + const enumValues = prop.enum || prop.items?.enum; + if (enumValues) parts.push(`[${enumValues.join(", ")}]`); + if (prop.maxLength !== undefined) parts.push(`(max length: ${prop.maxLength})`); + if (prop.default !== undefined) parts.push(`(default: ${JSON.stringify(prop.default)})`); + + return parts.join(" ") || undefined; +} + export function parseValue(value: string, prop: SchemaProperty): unknown { if (prop.type === "integer" || prop.type === "number") { const n = Number(value); @@ -122,14 +134,8 @@ export function registerTools(program: Command, tools: ToolDef[]): void { for (const [propName, rawProp] of Object.entries(properties)) { const prop = resolveProperty(rawProp, defs); const flag = optionFlag(propName, prop); - const parts: string[] = []; - if (prop.description) parts.push(prop.description); - if (required.has(propName)) parts.push("(required)"); - const enumValues = prop.enum || prop.items?.enum; - if (enumValues) parts.push(`[${enumValues.join(", ")}]`); - if (prop.default !== undefined) parts.push(`(default: ${JSON.stringify(prop.default)})`); - - cmd.option(flag, parts.join(" ") || undefined); + + cmd.option(flag, optionDescription(prop, required.has(propName))); } cmd.action(async (options: Record) => { diff --git a/src/config.ts b/src/config.ts index 05c59f1..ac6e6a1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,6 +26,7 @@ export interface SchemaProperty { format?: string; description?: string; enum?: string[]; + maxLength?: number; items?: SchemaProperty; default?: unknown; examples?: unknown[]; @@ -58,7 +59,7 @@ export interface Config { config?: CLIConfig; } -export const TOOLS_CACHE_VERSION = 2; +export const TOOLS_CACHE_VERSION = 3; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours diff --git a/src/mcp.ts b/src/mcp.ts index ec83eb5..e490e7b 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -1,6 +1,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { loadConfig, saveConfig, isCacheValid, TOOLS_CACHE_VERSION, type ToolDef } from "./config.js"; +import { addReaderLanguageSchemas } from "./readerLanguageSchema.js"; import { VERSION } from "./version.js"; const MCP_URL = "https://mcp2.readwise.io/mcp"; @@ -42,7 +43,7 @@ export async function getTools(token: string, authType: "oauth" | "token", force if (!forceRefresh) { const config = await loadConfig(); if (isCacheValid(config)) { - return config.tools_cache!.tools; + return addReaderLanguageSchemas(config.tools_cache!.tools); } } @@ -53,7 +54,7 @@ export async function getTools(token: string, authType: "oauth" | "token", force await client.connect(transport, { timeout: MCP_TIMEOUT_MS }); const result = await client.listTools({}, { timeout: MCP_TIMEOUT_MS }); - const tools = result.tools as ToolDef[]; + const tools = addReaderLanguageSchemas(result.tools as ToolDef[]); // Cache const config = await loadConfig(); diff --git a/src/readerLanguageSchema.ts b/src/readerLanguageSchema.ts new file mode 100644 index 0000000..b4e6ee7 --- /dev/null +++ b/src/readerLanguageSchema.ts @@ -0,0 +1,53 @@ +import type { SchemaProperty, ToolDef } from "./config.js"; + +function languageProperty(description: string): SchemaProperty { + return { + anyOf: [{ type: "string", maxLength: 30 }, { type: "null" }], + default: null, + description, + examples: ["de", "en-US"], + }; +} + +function addLanguageProperty( + properties: Record | undefined, + description: string, +): Record | undefined { + if (!properties || properties.language) return properties; + return { ...properties, language: languageProperty(description) }; +} + +function addCreateDocumentLanguage(tool: ToolDef): ToolDef { + const properties = addLanguageProperty( + tool.inputSchema.properties, + "Language code for the document. When omitted, Reader will auto-detect it.", + ); + if (!properties || properties === tool.inputSchema.properties) return tool; + + return { ...tool, inputSchema: { ...tool.inputSchema, properties } }; +} + +function addBulkEditLanguage(tool: ToolDef): ToolDef { + const item = tool.inputSchema.$defs?.BulkEditDocumentMetadataItem; + const properties = addLanguageProperty(item?.properties, "The new language code for the document"); + if (!item || !properties || properties === item.properties) return tool; + + return { + ...tool, + inputSchema: { + ...tool.inputSchema, + $defs: { + ...tool.inputSchema.$defs, + BulkEditDocumentMetadataItem: { ...item, properties }, + }, + }, + }; +} + +export function addReaderLanguageSchemas(tools: ToolDef[]): ToolDef[] { + return tools.map((tool) => { + if (tool.name === "reader_create_document") return addCreateDocumentLanguage(tool); + if (tool.name === "reader_bulk_edit_document_metadata") return addBulkEditLanguage(tool); + return tool; + }); +} diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 2d28ec6..3fcd9ac 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + optionDescription, optionFlag, parseValue, resolveProperty, @@ -18,6 +19,13 @@ test("optionFlag formats boolean and value options", () => { assert.equal(optionFlag("document_id", { type: "string" }), "--document-id "); }); +test("optionDescription includes schema constraints for generated help", () => { + assert.equal( + optionDescription({ type: "string", description: "Article language", maxLength: 30 }, true), + "Article language (required) (max length: 30)", + ); +}); + test("parseValue handles numbers, booleans, arrays, and strings", () => { assert.equal(parseValue("42", { type: "integer" }), 42); assert.equal(parseValue("3.14", { type: "number" }), 3.14); diff --git a/tests/config.test.ts b/tests/config.test.ts index eb2b97c..25fc2da 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -68,6 +68,10 @@ test("isCacheValid requires a matching cache version and fresh timestamp", () => assert.equal(isCacheValid({}), false); }); +test("tool cache version invalidates schemas before Reader language support", () => { + assert.equal(TOOLS_CACHE_VERSION, 3); +}); + test("filterReadOnlyTools keeps only tools explicitly annotated read-only", () => { const tools: ToolDef[] = [ { diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 9a60f8e..45daab2 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createMcpFetch } from "../src/mcp.js"; +import { addReaderLanguageSchemas } from "../src/readerLanguageSchema.js"; +import type { ToolDef } from "../src/config.js"; test("mcp fetch opts out of the optional GET SSE stream", async () => { let delegated = false; @@ -44,3 +46,57 @@ test("mcp fetch reads the method from Request inputs", async () => { assert.equal(response.status, 200); assert.equal(delegated, true); }); + +test("known schema updates add Reader create language support when missing", () => { + const tools: ToolDef[] = [{ + name: "reader_create_document", + inputSchema: { + type: "object", + properties: { + url: { type: "string" }, + summary: { anyOf: [{ type: "string" }, { type: "null" }], default: null }, + title: { anyOf: [{ type: "string" }, { type: "null" }], default: null }, + }, + }, + }]; + + const [tool] = addReaderLanguageSchemas(tools); + const properties = tool!.inputSchema.properties!; + + assert.deepEqual(Object.keys(properties), ["url", "summary", "title", "language"]); + assert.equal(properties.language?.description, "Language code for the document. When omitted, Reader will auto-detect it."); + assert.equal(properties.language?.anyOf?.[0]?.maxLength, 30); +}); + +test("known schema updates add Reader bulk edit language support when missing", () => { + const tools: ToolDef[] = [{ + name: "reader_bulk_edit_document_metadata", + inputSchema: { + type: "object", + properties: { + documents: { + type: "array", + items: { $ref: "#/$defs/BulkEditDocumentMetadataItem" }, + }, + }, + $defs: { + BulkEditDocumentMetadataItem: { + type: "object", + properties: { + document_id: { type: "string" }, + summary: { anyOf: [{ type: "string" }, { type: "null" }], default: null }, + seen: { anyOf: [{ type: "boolean" }, { type: "null" }], default: null }, + }, + required: ["document_id"], + }, + }, + }, + }]; + + const [tool] = addReaderLanguageSchemas(tools); + const properties = tool!.inputSchema.$defs!.BulkEditDocumentMetadataItem!.properties!; + + assert.deepEqual(Object.keys(properties), ["document_id", "summary", "seen", "language"]); + assert.equal(properties.language?.description, "The new language code for the document"); + assert.equal(properties.language?.anyOf?.[0]?.maxLength, 30); +});