Skip to content
Closed
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```
Expand All @@ -114,6 +115,7 @@ readwise reader-move-documents --document-ids <id> --location archive
readwise reader-bulk-edit-document-metadata --documents '[{"document_id":"<id>","title":"Better Title"}]'
readwise reader-bulk-edit-document-metadata --documents '[{"document_id":"<id>","seen":true}]'
readwise reader-bulk-edit-document-metadata --documents '[{"document_id":"<id>","notes":"Updated notes"}]'
readwise reader-bulk-edit-document-metadata --documents '[{"document_id":"<id>","language":"es"}]'
```

### Highlight management
Expand Down
22 changes: 14 additions & 8 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ export function optionFlag(name: string, prop: SchemaProperty): string {
return `${flag} <value>`;
}

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);
Expand Down Expand Up @@ -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<string, string>) => {
Expand Down
3 changes: 2 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface SchemaProperty {
format?: string;
description?: string;
enum?: string[];
maxLength?: number;
items?: SchemaProperty;
default?: unknown;
examples?: unknown[];
Expand Down Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions src/mcp.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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();
Expand Down
53 changes: 53 additions & 0 deletions src/readerLanguageSchema.ts
Original file line number Diff line number Diff line change
@@ -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<string, SchemaProperty> | undefined,
description: string,
): Record<string, SchemaProperty> | 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;
});
}
8 changes: 8 additions & 0 deletions tests/commands.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
optionDescription,
optionFlag,
parseValue,
resolveProperty,
Expand All @@ -18,6 +19,13 @@ test("optionFlag formats boolean and value options", () => {
assert.equal(optionFlag("document_id", { type: "string" }), "--document-id <value>");
});

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);
Expand Down
4 changes: 4 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
{
Expand Down
56 changes: 56 additions & 0 deletions tests/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
});
Loading