├── .nvmrc ├── .prettierrc.json ├── .gitignore ├── image.png ├── .husky └── pre-commit ├── .cz.json ├── src ├── tools │ ├── index.ts │ ├── get-book-by-title │ │ ├── types.ts │ │ ├── index.ts │ │ └── index.test.ts │ ├── get-authors-by-name │ │ ├── types.ts │ │ ├── index.ts │ │ └── index.test.ts │ ├── get-author-info │ │ ├── types.ts │ │ ├── index.ts │ │ └── index.test.ts │ ├── get-book-cover │ │ ├── index.ts │ │ └── index.test.ts │ ├── get-author-photo │ │ ├── index.ts │ │ └── index.test.ts │ └── get-book-by-id │ │ ├── types.ts │ │ ├── index.ts │ │ └── index.test.ts ├── index.ts └── index.test.ts ├── tsconfig.json ├── smithery.yaml ├── .eslintrc.json ├── LICENSE.md ├── Dockerfile ├── eslint.config.mjs ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.21.1 -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/8enSmith/mcp-open-library/HEAD/image.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm pkg set scripts.test:precommit="vitest run" 2 | npx lint-staged && npm run test:precommit 3 | -------------------------------------------------------------------------------- /.cz.json: -------------------------------------------------------------------------------- 1 | { 2 | "commitizen": { 3 | "name": "cz_conventional_commits", 4 | "tag_format": "$version", 5 | "version_scheme": "semver", 6 | "version_provider": "npm", 7 | "update_changelog_on_bump": true, 8 | "major_version_zero": true 9 | } 10 | } -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./get-book-by-title/index.js"; 2 | export * from "./get-authors-by-name/index.js"; 3 | export * from "./get-author-photo/index.js"; 4 | export * from "./get-book-cover/index.js"; 5 | export * from "./get-author-info/index.js"; 6 | export * from "./get-book-by-id/index.js"; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "nodenext", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | description: No configuration needed 9 | commandFunction: 10 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio. 11 | |- 12 | (config) => ({ command: 'node', args: ['build/index.js'] }) 13 | exampleConfig: {} 14 | -------------------------------------------------------------------------------- /src/tools/get-book-by-title/types.ts: -------------------------------------------------------------------------------- 1 | export interface OpenLibraryDoc { 2 | title: string; 3 | author_name?: string[]; 4 | first_publish_year?: number; 5 | key: string; // Work key, e.g., "/works/OL45883W" 6 | edition_count: number; 7 | cover_i?: number; // Add optional cover ID 8 | } 9 | 10 | export interface OpenLibrarySearchResponse { 11 | docs: OpenLibraryDoc[]; 12 | } 13 | 14 | export interface BookInfo { 15 | title: string; 16 | authors: string[]; 17 | first_publish_year: number | null; 18 | open_library_work_key: string; 19 | edition_count: number; 20 | cover_url?: string; 21 | } 22 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint", 6 | "import" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "prettier" 12 | ], 13 | "rules": { 14 | "import/order": [ 15 | "error", 16 | { 17 | "groups": ["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"], 18 | "newlines-between": "always", 19 | "alphabetize": { "order": "asc", "caseInsensitive": true } 20 | } 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/tools/get-authors-by-name/types.ts: -------------------------------------------------------------------------------- 1 | export interface OpenLibraryAuthorDoc { 2 | key: string; 3 | type: string; 4 | name: string; 5 | alternate_names?: string[]; 6 | birth_date?: string; 7 | top_work?: string; 8 | work_count: number; 9 | top_subjects?: string[]; 10 | _version_?: number; 11 | } 12 | 13 | export interface OpenLibraryAuthorSearchResponse { 14 | numFound: number; 15 | start: number; 16 | numFoundExact: boolean; 17 | docs: OpenLibraryAuthorDoc[]; 18 | } 19 | 20 | export interface AuthorInfo { 21 | key: string; 22 | name: string; 23 | alternate_names?: string[]; 24 | birth_date?: string; 25 | top_work?: string; 26 | work_count: number; 27 | } 28 | -------------------------------------------------------------------------------- /src/tools/get-author-info/types.ts: -------------------------------------------------------------------------------- 1 | // Add type for detailed author info 2 | export interface DetailedAuthorInfo { 3 | name: string; 4 | personal_name?: string; 5 | birth_date?: string; 6 | death_date?: string; 7 | bio?: string | { type: string; value: string }; // Bio can be string or object 8 | alternate_names?: string[]; 9 | links?: { title: string; url: string; type: { key: string } }[]; 10 | photos?: number[]; // Array of cover IDs 11 | source_records?: string[]; 12 | wikipedia?: string; 13 | key: string; 14 | remote_ids?: { 15 | amazon?: string; 16 | librarything?: string; 17 | viaf?: string; 18 | goodreads?: string; 19 | storygraph?: string; 20 | wikidata?: string; 21 | isni?: string; 22 | }; 23 | latest_revision?: number; 24 | revision: number; 25 | created?: { type: string; value: string }; 26 | last_modified: { type: string; value: string }; 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ben Smith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bullseye-slim 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive \ 4 | GLAMA_VERSION="0.2.0" \ 5 | PATH="/home/service-user/.local/bin:${PATH}" 6 | 7 | RUN (groupadd -r service-user) && (useradd -u 1987 -r -m -g service-user service-user) && (mkdir -p /home/service-user/.local/bin /app) && (chown -R service-user:service-user /home/service-user /app) && (apt-get update) && (apt-get install -y --no-install-recommends build-essential curl wget software-properties-common libssl-dev zlib1g-dev git) && (rm -rf /var/lib/apt/lists/*) && (curl -fsSL https://deb.nodesource.com/setup_22.x | bash -) && (apt-get install -y nodejs) && (apt-get clean) && (npm install -g mcp-proxy@2.10.6) && (npm install -g pnpm@9.15.5) && (npm install -g bun@1.1.42) && (node --version) && (curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR="/usr/local/bin" sh) && (uv python install 3.13 --default --preview) && (ln -s $(uv python find) /usr/local/bin/python) && (python --version) && (apt-get clean) && (rm -rf /var/lib/apt/lists/*) && (rm -rf /tmp/*) && (rm -rf /var/tmp/*) && (su - service-user -c "uv python install 3.13 --default --preview && python --version") 8 | 9 | USER service-user 10 | 11 | WORKDIR /app 12 | 13 | RUN git clone https://github.com/8enSmith/mcp-open-library . && git checkout main 14 | 15 | RUN (pnpm install || true) && (pnpm run build || true) 16 | 17 | EXPOSE 8080 18 | 19 | CMD ["mcp-proxy","node","./build/index.js"] 20 | -------------------------------------------------------------------------------- /src/tools/get-book-cover/index.ts: -------------------------------------------------------------------------------- 1 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 2 | import { z } from "zod"; 3 | 4 | // Schema for the get_book_cover tool arguments 5 | export const GetBookCoverArgsSchema = z.object({ 6 | key: z.enum(["ISBN", "OCLC", "LCCN", "OLID", "ID"], { 7 | errorMap: () => ({ 8 | message: "Key must be one of ISBN, OCLC, LCCN, OLID, ID", 9 | }), 10 | }), 11 | value: z.string().min(1, { message: "Value cannot be empty" }), 12 | size: z 13 | .nullable(z.enum(["S", "M", "L"])) 14 | .optional() 15 | .transform((val) => val || "L"), 16 | }); 17 | 18 | const handleGetBookCover = async (args: unknown) => { 19 | const parseResult = GetBookCoverArgsSchema.safeParse(args); 20 | 21 | if (!parseResult.success) { 22 | const errorMessages = parseResult.error.errors 23 | .map((e) => `${e.path.join(".")}: ${e.message}`) 24 | .join(", "); 25 | throw new McpError( 26 | ErrorCode.InvalidParams, 27 | `Invalid arguments for get_book_cover: ${errorMessages}`, 28 | ); 29 | } 30 | 31 | const { key, value, size } = parseResult.data; 32 | 33 | // Construct the URL according to the Open Library Covers API format 34 | const coverUrl = `https://covers.openlibrary.org/b/${key.toLowerCase()}/${value}-${size}.jpg`; 35 | 36 | return { 37 | content: [ 38 | { 39 | type: "text", 40 | text: coverUrl, 41 | }, 42 | ], 43 | }; 44 | // No try/catch needed here as we are just constructing a URL string based on validated input. 45 | }; 46 | 47 | export { handleGetBookCover }; 48 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { fileURLToPath } from "node:url"; 3 | 4 | import { fixupPluginRules } from "@eslint/compat"; 5 | import { FlatCompat } from "@eslint/eslintrc"; 6 | import js from "@eslint/js"; 7 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 8 | import tsParser from "@typescript-eslint/parser"; 9 | import { defineConfig } from "eslint/config"; 10 | import _import from "eslint-plugin-import"; 11 | 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = path.dirname(__filename); 14 | const compat = new FlatCompat({ 15 | baseDirectory: __dirname, 16 | recommendedConfig: js.configs.recommended, 17 | allConfig: js.configs.all 18 | }); 19 | 20 | export default defineConfig([{ 21 | extends: compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"), 22 | 23 | plugins: { 24 | "@typescript-eslint": typescriptEslint, 25 | import: fixupPluginRules(_import), 26 | }, 27 | 28 | languageOptions: { 29 | parser: tsParser, 30 | }, 31 | 32 | rules: { 33 | "import/order": ["error", { 34 | groups: [ 35 | "builtin", 36 | "external", 37 | "internal", 38 | "parent", 39 | "sibling", 40 | "index", 41 | "object", 42 | "type", 43 | ], 44 | 45 | "newlines-between": "always", 46 | 47 | alphabetize: { 48 | order: "asc", 49 | caseInsensitive: true, 50 | }, 51 | }], 52 | }, 53 | }]); -------------------------------------------------------------------------------- /src/tools/get-author-photo/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallToolResult, 3 | ErrorCode, 4 | McpError, 5 | } from "@modelcontextprotocol/sdk/types.js"; 6 | import { z } from "zod"; 7 | 8 | // Schema for the get_author_photo tool arguments 9 | export const GetAuthorPhotoArgsSchema = z.object({ 10 | olid: z 11 | .string() 12 | .min(1, { message: "OLID cannot be empty" }) 13 | .regex(/^OL\d+A$/, { 14 | // Escaped backslash for regex in string 15 | message: "OLID must be in the format OLA", 16 | }), 17 | }); 18 | 19 | // Handler function for the get_author_photo tool 20 | const handleGetAuthorPhoto = async (args: unknown): Promise => { 21 | const parseResult = GetAuthorPhotoArgsSchema.safeParse(args); 22 | 23 | if (!parseResult.success) { 24 | const errorMessages = parseResult.error.errors 25 | .map((e) => `${e.path.join(".")}: ${e.message}`) 26 | .join(", "); 27 | throw new McpError( 28 | ErrorCode.InvalidParams, 29 | `Invalid arguments for get_author_photo: ${errorMessages}`, 30 | ); 31 | } 32 | 33 | const olid = parseResult.data.olid; 34 | const photoUrl = `https://covers.openlibrary.org/a/olid/${olid}-L.jpg`; // Use -L for large size 35 | 36 | // Note: We don't actually fetch the image here, just return the URL. 37 | // The Open Library Covers API doesn't provide a way to check if an image exists 38 | // other than trying to fetch it. We assume the URL is correct if the OLID format is valid. 39 | 40 | return { 41 | content: [ 42 | { 43 | type: "text", 44 | text: photoUrl, 45 | }, 46 | ], 47 | }; 48 | // No try/catch needed here as we are just constructing a URL string based on validated input. 49 | }; 50 | 51 | export { handleGetAuthorPhoto }; 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mcp-open-library", 3 | "version": "1.0.0", 4 | "description": "MCP server for the Open Library", 5 | "private": false, 6 | "keywords": [ 7 | "mcp", 8 | "open-library", 9 | "books", 10 | "library", 11 | "api", 12 | "server" 13 | ], 14 | "author": "Ben Smith", 15 | "license": "MIT", 16 | "homepage": "https://github.com/8enSmith/mcp-open-library#readme", 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/8enSmith/mcp-open-library.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/8enSmith/mcp-open-library/issues" 23 | }, 24 | "type": "module", 25 | "bin": { 26 | "open-library-server": "./build/index.js" 27 | }, 28 | "files": [ 29 | "build" 30 | ], 31 | "scripts": { 32 | "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", 33 | "prepare": "npm run build", 34 | "watch": "tsc --watch", 35 | "inspector": "npx @modelcontextprotocol/inspector@0.17.2 build/index.js", 36 | "test": "vitest", 37 | "format": "prettier --write src/**/*.ts", 38 | "lint": "eslint src --ext .ts", 39 | "lint:fix": "eslint src --ext .ts --fix", 40 | "test:precommit": "vitest run" 41 | }, 42 | "dependencies": { 43 | "@modelcontextprotocol/sdk": "^1.16.0", 44 | "axios": "^1.10.0", 45 | "zod": "^3.24.2" 46 | }, 47 | "devDependencies": { 48 | "@eslint/compat": "^1.2.8", 49 | "@eslint/eslintrc": "^3.3.1", 50 | "@eslint/js": "^9.25.0", 51 | "@types/node": "^20.11.24", 52 | "@typescript-eslint/eslint-plugin": "^8.30.1", 53 | "@typescript-eslint/parser": "^8.30.1", 54 | "eslint": "^9.24.0", 55 | "eslint-config-prettier": "^10.1.2", 56 | "eslint-plugin-import": "^2.31.0", 57 | "husky": "^9.1.7", 58 | "lint-staged": "^15.5.1", 59 | "prettier": "^3.5.3", 60 | "typescript": "^5.3.3", 61 | "vitest": "^3.1.1" 62 | }, 63 | "lint-staged": { 64 | "*.ts": [ 65 | "eslint --fix", 66 | "prettier --write" 67 | ] 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/tools/get-author-photo/index.test.ts: -------------------------------------------------------------------------------- 1 | import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; 2 | import { describe, it, expect } from "vitest"; 3 | 4 | import { handleGetAuthorPhoto } from "./index.js"; 5 | 6 | describe("handleGetAuthorPhoto", () => { 7 | it("should return the correct photo URL for a valid OLID", async () => { 8 | const args = { olid: "OL23919A" }; // Example valid OLID 9 | const expectedUrl = "https://covers.openlibrary.org/a/olid/OL23919A-L.jpg"; 10 | const result = await handleGetAuthorPhoto(args); 11 | expect(result).toEqual({ 12 | content: [ 13 | { 14 | type: "text", 15 | text: expectedUrl, 16 | }, 17 | ], 18 | }); 19 | }); 20 | 21 | it("should throw McpError for invalid OLID format", async () => { 22 | const args = { olid: "invalid-olid-format" }; 23 | await expect(handleGetAuthorPhoto(args)).rejects.toThrow( 24 | new McpError( 25 | ErrorCode.InvalidParams, 26 | "Invalid arguments for get_author_photo: olid: OLID must be in the format OLA", 27 | ), 28 | ); 29 | }); 30 | 31 | it("should throw McpError for empty OLID", async () => { 32 | const args = { olid: "" }; 33 | await expect(handleGetAuthorPhoto(args)).rejects.toThrow( 34 | new McpError( 35 | ErrorCode.InvalidParams, 36 | "Invalid arguments for get_author_photo: olid: OLID cannot be empty, olid: OLID must be in the format OLA", 37 | ), 38 | ); 39 | }); 40 | 41 | it("should throw McpError if OLID is missing", async () => { 42 | const args = {}; // Missing olid property 43 | // Zod's default message for required fields might vary slightly, adjust if needed 44 | await expect(handleGetAuthorPhoto(args)).rejects.toThrow( 45 | new McpError( 46 | ErrorCode.InvalidParams, 47 | "Invalid arguments for get_author_photo: olid: Required", 48 | ), 49 | ); 50 | }); 51 | 52 | it("should throw McpError for non-object arguments", async () => { 53 | const args = null; // Invalid argument type 54 | await expect(handleGetAuthorPhoto(args)).rejects.toThrow( 55 | new McpError( 56 | ErrorCode.InvalidParams, 57 | "Invalid arguments for get_author_photo: : Expected object, received null", 58 | ), 59 | ); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/tools/get-authors-by-name/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallToolResult, 3 | ErrorCode, 4 | McpError, 5 | } from "@modelcontextprotocol/sdk/types.js"; 6 | import axios from "axios"; 7 | import { z } from "zod"; 8 | 9 | import { AuthorInfo, OpenLibraryAuthorSearchResponse } from "./types.js"; 10 | 11 | // Schema for the get_authors_by_name tool arguments 12 | export const GetAuthorsByNameArgsSchema = z.object({ 13 | name: z.string().min(1, { message: "Author name cannot be empty" }), 14 | }); 15 | 16 | // Type for the Axios instance 17 | type AxiosInstance = ReturnType; 18 | 19 | const handleGetAuthorsByName = async ( 20 | args: unknown, 21 | axiosInstance: AxiosInstance, 22 | ): Promise => { 23 | const parseResult = GetAuthorsByNameArgsSchema.safeParse(args); 24 | 25 | if (!parseResult.success) { 26 | const errorMessages = parseResult.error.errors 27 | .map((e) => `${e.path.join(".")}: ${e.message}`) 28 | .join(", "); 29 | throw new McpError( 30 | ErrorCode.InvalidParams, 31 | `Invalid arguments for get_authors_by_name: ${errorMessages}`, 32 | ); 33 | } 34 | 35 | const authorName = parseResult.data.name; 36 | 37 | try { 38 | const response = await axiosInstance.get( 39 | "/search/authors.json", // Use the author search endpoint 40 | { 41 | params: { q: authorName }, // Use 'q' parameter for author search 42 | }, 43 | ); 44 | 45 | if ( 46 | !response.data || 47 | !response.data.docs || 48 | response.data.docs.length === 0 49 | ) { 50 | return { 51 | content: [ 52 | { 53 | type: "text", 54 | text: `No authors found matching name: "${authorName}"`, 55 | }, 56 | ], 57 | }; 58 | } 59 | 60 | const authorResults: AuthorInfo[] = response.data.docs.map((doc) => ({ 61 | key: doc.key, 62 | name: doc.name, 63 | alternate_names: doc.alternate_names, 64 | birth_date: doc.birth_date, 65 | top_work: doc.top_work, 66 | work_count: doc.work_count, 67 | })); 68 | 69 | return { 70 | content: [ 71 | { 72 | type: "text", 73 | text: JSON.stringify(authorResults, null, 2), 74 | }, 75 | ], 76 | }; 77 | } catch (error) { 78 | let errorMessage = "Failed to fetch author data from Open Library."; 79 | if (axios.isAxiosError(error)) { 80 | errorMessage = `Open Library API error: ${ 81 | error.response?.statusText ?? error.message 82 | }`; 83 | } else if (error instanceof Error) { 84 | errorMessage = `Error processing request: ${error.message}`; 85 | } 86 | console.error("Error in get_authors_by_name:", error); 87 | return { 88 | content: [ 89 | { 90 | type: "text", 91 | text: errorMessage, 92 | }, 93 | ], 94 | isError: true, 95 | }; 96 | } 97 | }; 98 | 99 | export { handleGetAuthorsByName }; 100 | -------------------------------------------------------------------------------- /src/tools/get-book-by-id/types.ts: -------------------------------------------------------------------------------- 1 | interface OpenLibraryIdentifier { 2 | isbn_10?: string[]; 3 | isbn_13?: string[]; 4 | lccn?: string[]; 5 | oclc?: string[]; 6 | openlibrary?: string[]; 7 | } 8 | 9 | interface OpenLibraryAuthor { 10 | url?: string; 11 | name: string; 12 | } 13 | 14 | interface OpenLibraryPublisher { 15 | name: string; 16 | } 17 | 18 | interface OpenLibraryCover { 19 | small?: string; 20 | medium?: string; 21 | large?: string; 22 | } 23 | 24 | // Represents the structure within the 'data' field of a record 25 | interface OpenLibraryRecordData { 26 | url: string; 27 | key: string; // e.g., "/books/OL24194264M" 28 | title: string; 29 | subtitle?: string; 30 | authors?: OpenLibraryAuthor[]; 31 | number_of_pages?: number; 32 | identifiers?: OpenLibraryIdentifier; 33 | publishers?: OpenLibraryPublisher[]; 34 | publish_date?: string; 35 | subjects?: { name: string; url: string }[]; 36 | ebooks?: { preview_url?: string; availability?: string; read_url?: string }[]; 37 | cover?: OpenLibraryCover; 38 | } 39 | 40 | // Represents the structure within the 'details' field of a record 41 | interface OpenLibraryRecordDetails { 42 | bib_key: string; 43 | info_url: string; 44 | preview?: string; 45 | preview_url?: string; 46 | thumbnail_url?: string; 47 | details?: { 48 | // Yes, there's another nested 'details' sometimes 49 | key: string; 50 | title: string; 51 | authors?: OpenLibraryAuthor[]; 52 | publishers?: OpenLibraryPublisher[]; 53 | publish_date?: string; 54 | works?: { key: string }[]; // e.g., "/works/OL15610910W" 55 | covers?: number[]; // Cover IDs, not URLs 56 | lccn?: string[]; 57 | oclc_numbers?: string[]; 58 | isbn_10?: string[]; 59 | isbn_13?: string[]; 60 | number_of_pages?: number; 61 | // ... other potential fields 62 | }; 63 | } 64 | 65 | // Represents a single record in the 'records' object 66 | export interface OpenLibraryRecord { 67 | recordURL: string; 68 | data: OpenLibraryRecordData; 69 | details: OpenLibraryRecordDetails; // This is the details object we need 70 | // ... other potential fields like isbns, olids etc. at this level 71 | } 72 | 73 | // Type for the overall API response structure 74 | export interface OpenLibraryBookResponse { 75 | // The keys are dynamic (e.g., "/books/OL24194264M") 76 | records: Record; 77 | items: unknown[]; // Items might not be needed for core details 78 | } 79 | 80 | // --- Formatted Book Details returned by the tool --- // 81 | 82 | export interface BookDetails { 83 | title: string; 84 | subtitle?: string; 85 | authors: string[]; 86 | publishers?: string[]; 87 | publish_date?: string; 88 | number_of_pages?: number; 89 | isbn_13?: string[]; 90 | isbn_10?: string[]; 91 | lccn?: string[]; 92 | oclc?: string[]; 93 | olid?: string[]; // Add OLID field 94 | open_library_edition_key: string; // e.g., "/books/OL24194264M" 95 | open_library_work_key?: string; // e.g., "/works/OL15610910W" 96 | cover_url?: string; 97 | info_url: string; 98 | preview_url?: string; 99 | } 100 | -------------------------------------------------------------------------------- /src/tools/get-author-info/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallToolResult, 3 | ErrorCode, 4 | McpError, 5 | } from "@modelcontextprotocol/sdk/types.js"; 6 | import axios from "axios"; 7 | import { z } from "zod"; 8 | 9 | import { DetailedAuthorInfo } from "./types.js"; 10 | 11 | // Schema for the get_author_info tool arguments 12 | export const GetAuthorInfoArgsSchema = z.object({ 13 | author_key: z 14 | .string() 15 | .min(1, { message: "Author key cannot be empty" }) 16 | .regex(/^OL\d+A$/, { 17 | message: "Author key must be in the format OLA", 18 | }), 19 | }); 20 | 21 | // Type for the Axios instance (can be imported or defined if needed elsewhere) 22 | type AxiosInstance = ReturnType; 23 | 24 | const handleGetAuthorInfo = async ( 25 | args: unknown, 26 | axiosInstance: AxiosInstance, 27 | ): Promise => { 28 | const parseResult = GetAuthorInfoArgsSchema.safeParse(args); 29 | 30 | if (!parseResult.success) { 31 | const errorMessages = parseResult.error.errors 32 | .map((e) => `${e.path.join(".")}: ${e.message}`) 33 | .join(", "); 34 | throw new McpError( 35 | ErrorCode.InvalidParams, 36 | `Invalid arguments for get_author_info: ${errorMessages}`, 37 | ); 38 | } 39 | 40 | const authorKey = parseResult.data.author_key; 41 | 42 | try { 43 | const response = await axiosInstance.get( 44 | `/authors/${authorKey}.json`, 45 | ); 46 | 47 | if (!response.data) { 48 | // Should not happen if API returns 200, but good practice 49 | return { 50 | content: [ 51 | { 52 | type: "text", 53 | text: `No data found for author key: "${authorKey}"`, 54 | }, 55 | ], 56 | }; 57 | } 58 | 59 | // Optionally format the bio if it's an object 60 | const authorData = { ...response.data }; 61 | if (typeof authorData.bio === "object" && authorData.bio !== null) { 62 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 63 | authorData.bio = (authorData.bio as any).value; // Adjust type assertion if needed 64 | } 65 | 66 | return { 67 | content: [ 68 | { 69 | type: "text", 70 | // Return the full author details as JSON 71 | text: JSON.stringify(authorData, null, 2), 72 | }, 73 | ], 74 | }; 75 | } catch (error) { 76 | let errorMessage = `Failed to fetch author data for key ${authorKey}.`; 77 | if (axios.isAxiosError(error)) { 78 | if (error.response?.status === 404) { 79 | errorMessage = `Author with key "${authorKey}" not found.`; 80 | } else { 81 | errorMessage = `Open Library API error: ${ 82 | error.response?.statusText ?? error.message 83 | }`; 84 | } 85 | } else if (error instanceof Error) { 86 | errorMessage = `Error processing request: ${error.message}`; 87 | } 88 | console.error(`Error in get_author_info (${authorKey}):`, error); 89 | 90 | return { 91 | content: [ 92 | { 93 | type: "text", 94 | text: errorMessage, 95 | }, 96 | ], 97 | isError: true, 98 | }; 99 | } 100 | }; 101 | 102 | export { handleGetAuthorInfo }; 103 | -------------------------------------------------------------------------------- /src/tools/get-book-by-title/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallToolResult, 3 | ErrorCode, 4 | McpError, 5 | } from "@modelcontextprotocol/sdk/types.js"; 6 | import axios from "axios"; 7 | import { z } from "zod"; 8 | 9 | import { BookInfo, OpenLibrarySearchResponse } from "./types.js"; 10 | 11 | // Schema for the get_book_by_title tool arguments 12 | export const GetBookByTitleArgsSchema = z.object({ 13 | title: z.string().min(1, { message: "Title cannot be empty" }), 14 | }); 15 | 16 | // Type for the Axios instance (can be imported or defined if needed elsewhere) 17 | type AxiosInstance = ReturnType; 18 | 19 | const handleGetBookByTitle = async ( 20 | args: unknown, 21 | axiosInstance: AxiosInstance, 22 | ): Promise => { 23 | const parseResult = GetBookByTitleArgsSchema.safeParse(args); 24 | 25 | if (!parseResult.success) { 26 | const errorMessages = parseResult.error.errors 27 | .map((e) => `${e.path.join(".")}: ${e.message}`) 28 | .join(", "); 29 | throw new McpError( 30 | ErrorCode.InvalidParams, 31 | `Invalid arguments for get_book_by_title: ${errorMessages}`, 32 | ); 33 | } 34 | 35 | const bookTitle = parseResult.data.title; 36 | 37 | try { 38 | const response = await axiosInstance.get( 39 | "/search.json", 40 | { 41 | params: { title: bookTitle }, 42 | }, 43 | ); 44 | 45 | if ( 46 | !response.data || 47 | !response.data.docs || 48 | response.data.docs.length === 0 49 | ) { 50 | return { 51 | content: [ 52 | { 53 | type: "text", 54 | text: `No books found matching title: "${bookTitle}"`, 55 | }, 56 | ], 57 | }; 58 | } 59 | 60 | const bookResults = Array.isArray(response.data.docs) 61 | ? response.data.docs.map((doc) => { 62 | const bookInfo: BookInfo = { 63 | title: doc.title, 64 | authors: doc.author_name || [], 65 | first_publish_year: doc.first_publish_year || null, 66 | open_library_work_key: doc.key, 67 | edition_count: doc.edition_count || 0, 68 | }; 69 | 70 | if (doc.cover_i) { 71 | bookInfo.cover_url = `https://covers.openlibrary.org/b/id/${doc.cover_i}-M.jpg`; 72 | } 73 | 74 | return bookInfo; 75 | }) 76 | : []; 77 | 78 | return { 79 | content: [ 80 | { 81 | type: "text", 82 | text: JSON.stringify(bookResults, null, 2), 83 | }, 84 | ], 85 | }; 86 | } catch (error) { 87 | let errorMessage = "Failed to fetch book data from Open Library."; 88 | if (axios.isAxiosError(error)) { 89 | errorMessage = `Error processing request: ${ 90 | error.response?.statusText ?? error.message 91 | }`; 92 | } else if (error instanceof Error) { 93 | errorMessage = `Error processing request: ${error.message}`; 94 | } 95 | console.error("Error in get_book_by_title:", error); 96 | 97 | return { 98 | content: [ 99 | { 100 | type: "text", 101 | text: errorMessage, 102 | }, 103 | ], 104 | isError: true, 105 | }; 106 | } 107 | }; 108 | 109 | export { handleGetBookByTitle }; 110 | -------------------------------------------------------------------------------- /src/tools/get-book-cover/index.test.ts: -------------------------------------------------------------------------------- 1 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 2 | import { describe, expect, it } from "vitest"; 3 | 4 | import { handleGetBookCover } from "./index.js"; 5 | 6 | describe("handleGetBookCover", () => { 7 | it("should generate correct URL with ISBN", async () => { 8 | const result = await handleGetBookCover({ 9 | key: "ISBN", 10 | value: "0451526538", 11 | }); 12 | expect(result.content[0].text).toBe( 13 | "https://covers.openlibrary.org/b/isbn/0451526538-L.jpg", 14 | ); 15 | }); 16 | 17 | it("should generate correct URL with OLID", async () => { 18 | const result = await handleGetBookCover({ 19 | key: "OLID", 20 | value: "OL7353617M", 21 | }); 22 | expect(result.content[0].text).toBe( 23 | "https://covers.openlibrary.org/b/olid/OL7353617M-L.jpg", 24 | ); 25 | }); 26 | 27 | it("should handle different size parameters", async () => { 28 | const smallSize = await handleGetBookCover({ 29 | key: "ISBN", 30 | value: "0451526538", 31 | size: "S", 32 | }); 33 | expect(smallSize.content[0].text).toBe( 34 | "https://covers.openlibrary.org/b/isbn/0451526538-S.jpg", 35 | ); 36 | 37 | const mediumSize = await handleGetBookCover({ 38 | key: "ISBN", 39 | value: "0451526538", 40 | size: "M", 41 | }); 42 | expect(mediumSize.content[0].text).toBe( 43 | "https://covers.openlibrary.org/b/isbn/0451526538-M.jpg", 44 | ); 45 | 46 | const largeSize = await handleGetBookCover({ 47 | key: "ISBN", 48 | value: "0451526538", 49 | size: "L", 50 | }); 51 | expect(largeSize.content[0].text).toBe( 52 | "https://covers.openlibrary.org/b/isbn/0451526538-L.jpg", 53 | ); 54 | }); 55 | 56 | it("should default to large size when size is null or omitted", async () => { 57 | const nullSize = await handleGetBookCover({ 58 | key: "ISBN", 59 | value: "0451526538", 60 | size: null, 61 | }); 62 | expect(nullSize.content[0].text).toBe( 63 | "https://covers.openlibrary.org/b/isbn/0451526538-L.jpg", 64 | ); 65 | 66 | const omittedSize = await handleGetBookCover({ 67 | key: "ISBN", 68 | value: "0451526538", 69 | }); 70 | expect(omittedSize.content[0].text).toBe( 71 | "https://covers.openlibrary.org/b/isbn/0451526538-L.jpg", 72 | ); 73 | }); 74 | 75 | it("should throw error for invalid key", async () => { 76 | await expect( 77 | handleGetBookCover({ 78 | key: "INVALID_KEY", 79 | value: "0451526538", 80 | }), 81 | ).rejects.toThrow(McpError); 82 | 83 | try { 84 | await handleGetBookCover({ key: "INVALID_KEY", value: "0451526538" }); 85 | } catch (error) { 86 | expect(error).toBeInstanceOf(McpError); 87 | expect((error as McpError).code).toBe(ErrorCode.InvalidParams); 88 | expect((error as McpError).message).toContain( 89 | "Invalid arguments for get_book_cover", 90 | ); 91 | } 92 | }); 93 | 94 | it("should throw error for empty value", async () => { 95 | await expect( 96 | handleGetBookCover({ 97 | key: "ISBN", 98 | value: "", 99 | }), 100 | ).rejects.toThrow(McpError); 101 | 102 | try { 103 | await handleGetBookCover({ key: "ISBN", value: "" }); 104 | } catch (error) { 105 | expect(error).toBeInstanceOf(McpError); 106 | expect((error as McpError).code).toBe(ErrorCode.InvalidParams); 107 | expect((error as McpError).message).toContain("Value cannot be empty"); 108 | } 109 | }); 110 | 111 | it("should throw error for invalid size", async () => { 112 | await expect( 113 | handleGetBookCover({ 114 | key: "ISBN", 115 | value: "0451526538", 116 | size: "XL", 117 | }), 118 | ).rejects.toThrow(McpError); 119 | }); 120 | 121 | it("should throw error for missing required parameters", async () => { 122 | await expect(handleGetBookCover({})).rejects.toThrow(McpError); 123 | await expect(handleGetBookCover({ key: "ISBN" })).rejects.toThrow(McpError); 124 | await expect(handleGetBookCover({ value: "0451526538" })).rejects.toThrow( 125 | McpError, 126 | ); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /src/tools/get-book-by-id/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallToolResult, 3 | ErrorCode, 4 | McpError, 5 | } from "@modelcontextprotocol/sdk/types.js"; 6 | import axios from "axios"; 7 | import { z } from "zod"; 8 | 9 | import { 10 | BookDetails, 11 | OpenLibraryBookResponse, 12 | OpenLibraryRecord, // Import the updated record type 13 | } from "./types.js"; 14 | 15 | // Schema for the get_book_by_id tool arguments 16 | const GetBookByIdArgsSchema = z.object({ 17 | idType: z 18 | .string() 19 | .transform((val) => val.toLowerCase()) 20 | .pipe( 21 | z.enum(["isbn", "lccn", "oclc", "olid"], { 22 | errorMap: () => ({ 23 | message: "idType must be one of: isbn, lccn, oclc, olid", 24 | }), 25 | }), 26 | ), 27 | idValue: z.string().min(1, { message: "idValue cannot be empty" }), 28 | }); 29 | 30 | // Type for the Axios instance 31 | type AxiosInstance = ReturnType; 32 | 33 | export const handleGetBookById = async ( 34 | args: unknown, 35 | axiosInstance: AxiosInstance, 36 | ): Promise => { 37 | const parseResult = GetBookByIdArgsSchema.safeParse(args); 38 | 39 | if (!parseResult.success) { 40 | const errorMessages = parseResult.error.errors 41 | .map((e) => `${e.path.join(".")}: ${e.message}`) 42 | .join(", "); 43 | throw new McpError( 44 | ErrorCode.InvalidParams, 45 | `Invalid arguments for get_book_by_id: ${errorMessages}`, 46 | ); 47 | } 48 | 49 | const { idType, idValue } = parseResult.data; 50 | const apiUrl = `/api/volumes/brief/${idType}/${idValue}.json`; 51 | 52 | try { 53 | const response = await axiosInstance.get(apiUrl); 54 | 55 | // Check if records object exists and is not empty 56 | if ( 57 | !response.data || 58 | !response.data.records || 59 | Object.keys(response.data.records).length === 0 60 | ) { 61 | return { 62 | content: [ 63 | { 64 | type: "text", 65 | text: `No book found for ${idType}: ${idValue}`, 66 | }, 67 | ], 68 | }; 69 | } 70 | 71 | // Get the first record from the records object 72 | const recordKey = Object.keys(response.data.records)[0]; 73 | const record: OpenLibraryRecord | undefined = 74 | response.data.records[recordKey]; 75 | 76 | if (!record) { 77 | // This case should theoretically not happen if the length check passed, but good for safety 78 | return { 79 | content: [ 80 | { 81 | type: "text", 82 | text: `Could not process book record for ${idType}: ${idValue}`, 83 | }, 84 | ], 85 | }; 86 | } 87 | 88 | const recordData = record.data; 89 | const recordDetails = record.details?.details; // Access the nested details 90 | 91 | const bookDetails: BookDetails = { 92 | title: recordData.title, 93 | subtitle: recordData.subtitle, 94 | authors: recordData.authors?.map((a) => a.name) || [], 95 | publishers: recordData.publishers?.map((p) => p.name), 96 | publish_date: recordData.publish_date, 97 | number_of_pages: 98 | recordData.number_of_pages ?? recordDetails?.number_of_pages, 99 | // Prefer identifiers from recordData, fallback to recordDetails if necessary 100 | isbn_13: recordData.identifiers?.isbn_13 ?? recordDetails?.isbn_13, 101 | isbn_10: recordData.identifiers?.isbn_10 ?? recordDetails?.isbn_10, 102 | lccn: recordData.identifiers?.lccn ?? recordDetails?.lccn, 103 | oclc: recordData.identifiers?.oclc ?? recordDetails?.oclc_numbers, 104 | olid: recordData.identifiers?.openlibrary, // Add OLID from identifiers 105 | open_library_edition_key: recordData.key, // From recordData 106 | open_library_work_key: recordDetails?.works?.[0]?.key, // From nested details 107 | cover_url: recordData.cover?.medium, // Use medium cover from recordData 108 | info_url: record.details?.info_url ?? recordData.url, // Prefer info_url from details 109 | preview_url: 110 | record.details?.preview_url ?? recordData.ebooks?.[0]?.preview_url, 111 | }; 112 | 113 | // Clean up undefined fields 114 | Object.keys(bookDetails).forEach((key) => { 115 | const typedKey = key as keyof BookDetails; 116 | if ( 117 | bookDetails[typedKey] === undefined || 118 | ((typedKey === "authors" || typedKey === "publishers") && 119 | Array.isArray(bookDetails[typedKey]) && 120 | bookDetails[typedKey].length === 0) 121 | ) { 122 | delete bookDetails[typedKey]; 123 | } 124 | }); 125 | 126 | return { 127 | content: [ 128 | { 129 | type: "text", 130 | text: JSON.stringify(bookDetails, null, 2), 131 | }, 132 | ], 133 | }; 134 | } catch (error) { 135 | let errorMessage = "Failed to fetch book data from Open Library."; 136 | if (axios.isAxiosError(error)) { 137 | if (error.response?.status === 404) { 138 | errorMessage = `No book found for ${idType}: ${idValue}`; 139 | } else { 140 | errorMessage = `API Error: ${error.response?.statusText ?? error.message}`; 141 | } 142 | } else if (error instanceof Error) { 143 | errorMessage = `Error processing request: ${error.message}`; 144 | } 145 | console.error("Error in get_book_by_id:", error); 146 | 147 | // Return error as text content 148 | return { 149 | content: [ 150 | { 151 | type: "text", 152 | text: errorMessage, 153 | }, 154 | ], 155 | }; 156 | } 157 | }; 158 | -------------------------------------------------------------------------------- /src/tools/get-book-by-title/index.test.ts: -------------------------------------------------------------------------------- 1 | import { McpError } from "@modelcontextprotocol/sdk/types.js"; 2 | import { describe, expect, it, vi, beforeEach } from "vitest"; 3 | 4 | import { handleGetBookByTitle, GetBookByTitleArgsSchema } from "./index.js"; 5 | 6 | // Mock axios 7 | vi.mock("axios"); 8 | 9 | describe("handleGetBookByTitle", () => { 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | let mockAxiosInstance: any; 12 | 13 | beforeEach(() => { 14 | mockAxiosInstance = { 15 | get: vi.fn(), 16 | }; 17 | }); 18 | 19 | it("should return book data when title is valid and books are found", async () => { 20 | // Mock response data 21 | const mockResponseData = { 22 | docs: [ 23 | { 24 | title: "Test Book", 25 | author_name: ["Author One", "Author Two"], 26 | first_publish_year: 2020, 27 | key: "/works/test123", 28 | edition_count: 5, 29 | cover_i: 12345, 30 | }, 31 | ], 32 | }; 33 | 34 | mockAxiosInstance.get.mockResolvedValue({ data: mockResponseData }); 35 | 36 | const result = await handleGetBookByTitle( 37 | { title: "Test Book" }, 38 | mockAxiosInstance, 39 | ); 40 | 41 | expect(mockAxiosInstance.get).toHaveBeenCalledWith("/search.json", { 42 | params: { title: "Test Book" }, 43 | }); 44 | 45 | expect(result).toEqual({ 46 | content: [ 47 | { 48 | type: "text", 49 | text: JSON.stringify( 50 | [ 51 | { 52 | title: "Test Book", 53 | authors: ["Author One", "Author Two"], 54 | first_publish_year: 2020, 55 | open_library_work_key: "/works/test123", 56 | edition_count: 5, 57 | cover_url: "https://covers.openlibrary.org/b/id/12345-M.jpg", 58 | }, 59 | ], 60 | null, 61 | 2, 62 | ), 63 | }, 64 | ], 65 | }); 66 | }); 67 | 68 | it("should handle book with missing optional fields", async () => { 69 | // Mock response with missing optional fields 70 | const mockResponseData = { 71 | docs: [ 72 | { 73 | title: "Minimal Book", 74 | key: "/works/minimal123", 75 | }, 76 | ], 77 | }; 78 | 79 | mockAxiosInstance.get.mockResolvedValue({ data: mockResponseData }); 80 | 81 | const result = await handleGetBookByTitle( 82 | { title: "Minimal Book" }, 83 | mockAxiosInstance, 84 | ); 85 | 86 | expect(result.content[0].text).toContain("Minimal Book"); 87 | expect( 88 | ( 89 | JSON.parse(result.content[0].text as string) as Array<{ 90 | title: string; 91 | authors: string[]; 92 | first_publish_year: number | null; 93 | open_library_work_key: string; 94 | edition_count: number; 95 | cover_url?: string; 96 | }> 97 | )[0], 98 | ).toEqual({ 99 | title: "Minimal Book", 100 | authors: [], 101 | first_publish_year: null, 102 | open_library_work_key: "/works/minimal123", 103 | edition_count: 0, 104 | }); 105 | }); 106 | 107 | it("should return appropriate message when no books are found", async () => { 108 | mockAxiosInstance.get.mockResolvedValue({ data: { docs: [] } }); 109 | 110 | const result = await handleGetBookByTitle( 111 | { title: "Nonexistent Book" }, 112 | mockAxiosInstance, 113 | ); 114 | 115 | expect(result).toEqual({ 116 | content: [ 117 | { 118 | type: "text", 119 | text: 'No books found matching title: "Nonexistent Book"', 120 | }, 121 | ], 122 | }); 123 | }); 124 | 125 | it("should throw McpError for invalid arguments", async () => { 126 | await expect(async () => { 127 | await handleGetBookByTitle({ title: "" }, mockAxiosInstance); 128 | }).rejects.toThrow(McpError); 129 | 130 | await expect(async () => { 131 | await handleGetBookByTitle( 132 | { wrongParam: "something" }, 133 | mockAxiosInstance, 134 | ); 135 | }).rejects.toThrow(McpError); 136 | 137 | await expect(async () => { 138 | await handleGetBookByTitle(null, mockAxiosInstance); 139 | }).rejects.toThrow(McpError); 140 | }); 141 | 142 | it("should handle API errors properly", async () => { 143 | const axiosError = new Error("Network Error"); 144 | Object.defineProperty(axiosError, "isAxiosError", { value: true }); 145 | Object.defineProperty(axiosError, "response", { 146 | value: { statusText: "Service Unavailable" }, 147 | }); 148 | 149 | mockAxiosInstance.get.mockRejectedValue(axiosError); 150 | 151 | const result = await handleGetBookByTitle( 152 | { title: "Test Book" }, 153 | mockAxiosInstance, 154 | ); 155 | 156 | expect(result).toEqual({ 157 | content: [ 158 | { 159 | type: "text", 160 | text: "Error processing request: Network Error", 161 | }, 162 | ], 163 | isError: true, 164 | }); 165 | }); 166 | 167 | it("should handle non-axios errors", async () => { 168 | mockAxiosInstance.get.mockRejectedValue(new Error("Unknown Error")); 169 | 170 | const result = await handleGetBookByTitle( 171 | { title: "Test Book" }, 172 | mockAxiosInstance, 173 | ); 174 | 175 | expect(result).toEqual({ 176 | content: [ 177 | { 178 | type: "text", 179 | text: "Error processing request: Unknown Error", 180 | }, 181 | ], 182 | isError: true, 183 | }); 184 | }); 185 | 186 | describe("GetBookByTitleArgsSchema", () => { 187 | it("should validate correct input", () => { 188 | const result = GetBookByTitleArgsSchema.safeParse({ 189 | title: "Valid Title", 190 | }); 191 | expect(result.success).toBe(true); 192 | }); 193 | 194 | it("should reject empty title", () => { 195 | const result = GetBookByTitleArgsSchema.safeParse({ title: "" }); 196 | expect(result.success).toBe(false); 197 | }); 198 | 199 | it("should reject missing title", () => { 200 | const result = GetBookByTitleArgsSchema.safeParse({}); 201 | expect(result.success).toBe(false); 202 | }); 203 | }); 204 | }); 205 | -------------------------------------------------------------------------------- /src/tools/get-author-info/index.test.ts: -------------------------------------------------------------------------------- 1 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 2 | import { AxiosInstance, AxiosError, AxiosHeaders } from "axios"; 3 | import { describe, it, expect, vi, Mock } from "vitest"; 4 | 5 | import { handleGetAuthorInfo } from "./index.js"; 6 | 7 | // Mock Axios instance 8 | const mockAxiosInstance = { 9 | get: vi.fn(), 10 | } as unknown as AxiosInstance; 11 | 12 | describe("handleGetAuthorInfo", () => { 13 | it("should return author info for a valid author key", async () => { 14 | const mockAuthorData = { 15 | name: "J.R.R. Tolkien", 16 | key: "/authors/OL26320A", 17 | birth_date: "3 January 1892", 18 | death_date: "2 September 1973", 19 | bio: "British writer, poet, philologist, and academic.", 20 | }; 21 | (mockAxiosInstance.get as Mock).mockResolvedValue({ 22 | data: mockAuthorData, 23 | }); 24 | 25 | const result = await handleGetAuthorInfo( 26 | { author_key: "OL26320A" }, 27 | mockAxiosInstance, 28 | ); 29 | 30 | expect(mockAxiosInstance.get).toHaveBeenCalledWith( 31 | "/authors/OL26320A.json", 32 | ); 33 | expect(result.isError).toBeUndefined(); 34 | expect(result.content).toEqual([ 35 | { 36 | type: "text", 37 | text: JSON.stringify(mockAuthorData, null, 2), 38 | }, 39 | ]); 40 | }); 41 | 42 | it("should handle bio as an object", async () => { 43 | const mockAuthorDataWithObjectBio = { 44 | name: "George Orwell", 45 | key: "/authors/OL27346A", 46 | bio: { 47 | type: "/type/text", 48 | value: "English novelist, essayist, journalist and critic.", 49 | }, 50 | }; 51 | const expectedFormattedData = { 52 | name: "George Orwell", 53 | key: "/authors/OL27346A", 54 | bio: "English novelist, essayist, journalist and critic.", 55 | }; 56 | (mockAxiosInstance.get as Mock).mockResolvedValue({ 57 | data: mockAuthorDataWithObjectBio, 58 | }); 59 | 60 | const result = await handleGetAuthorInfo( 61 | { author_key: "OL27346A" }, 62 | mockAxiosInstance, 63 | ); 64 | 65 | expect(result.isError).toBeUndefined(); 66 | expect(result.content).toEqual([ 67 | { 68 | type: "text", 69 | text: JSON.stringify(expectedFormattedData, null, 2), 70 | }, 71 | ]); 72 | }); 73 | 74 | it("should throw McpError for invalid author key format", async () => { 75 | await expect( 76 | handleGetAuthorInfo({ author_key: "invalid-key" }, mockAxiosInstance), 77 | ).rejects.toThrow( 78 | new McpError( 79 | ErrorCode.InvalidParams, 80 | "Invalid arguments for get_author_info: author_key: Author key must be in the format OLA", 81 | ), 82 | ); 83 | }); 84 | 85 | it("should throw McpError for empty author key", async () => { 86 | await expect( 87 | handleGetAuthorInfo({ author_key: "" }, mockAxiosInstance), 88 | ).rejects.toThrow( 89 | new McpError( 90 | ErrorCode.InvalidParams, 91 | "Invalid arguments for get_author_info: author_key: Author key cannot be empty, author_key: Author key must be in the format OLA", 92 | ), 93 | ); 94 | }); 95 | 96 | it("should return an error message for a 404 Not Found response", async () => { 97 | const authorKey = "OL00000A"; 98 | const axiosError = new AxiosError( 99 | `Request failed with status code 404`, 100 | "404", 101 | undefined, 102 | undefined, 103 | { 104 | status: 404, 105 | statusText: "Not Found", 106 | headers: {}, 107 | config: { headers: new AxiosHeaders() }, 108 | data: {}, 109 | }, 110 | ); 111 | (mockAxiosInstance.get as Mock).mockRejectedValue(axiosError); 112 | 113 | const result = await handleGetAuthorInfo( 114 | { author_key: authorKey }, 115 | mockAxiosInstance, 116 | ); 117 | 118 | expect(result.isError).toBe(true); 119 | expect(result.content).toEqual([ 120 | { 121 | type: "text", 122 | text: `Author with key "${authorKey}" not found.`, 123 | }, 124 | ]); 125 | }); 126 | 127 | it("should return a generic API error message for other Axios errors", async () => { 128 | const authorKey = "OL12345A"; 129 | const axiosError = new AxiosError( 130 | `Request failed with status code 500`, 131 | "500", 132 | undefined, 133 | undefined, 134 | { 135 | status: 500, 136 | statusText: "Internal Server Error", 137 | headers: {}, 138 | config: { headers: new AxiosHeaders() }, 139 | data: {}, 140 | }, 141 | ); 142 | (mockAxiosInstance.get as Mock).mockRejectedValue(axiosError); 143 | 144 | const result = await handleGetAuthorInfo( 145 | { author_key: authorKey }, 146 | mockAxiosInstance, 147 | ); 148 | 149 | expect(result.isError).toBe(true); 150 | expect(result.content).toEqual([ 151 | { 152 | type: "text", 153 | text: `Open Library API error: Internal Server Error`, 154 | }, 155 | ]); 156 | }); 157 | 158 | it("should return a generic error message for non-Axios errors", async () => { 159 | const authorKey = "OL98765A"; 160 | const genericError = new Error("Network Error"); 161 | (mockAxiosInstance.get as Mock).mockRejectedValue(genericError); 162 | 163 | const result = await handleGetAuthorInfo( 164 | { author_key: authorKey }, 165 | mockAxiosInstance, 166 | ); 167 | 168 | expect(result.isError).toBe(true); 169 | expect(result.content).toEqual([ 170 | { 171 | type: "text", 172 | text: `Error processing request: Network Error`, 173 | }, 174 | ]); 175 | }); 176 | 177 | it("should return a message if API returns 200 but no data", async () => { 178 | const authorKey = "OL11111A"; 179 | (mockAxiosInstance.get as Mock).mockResolvedValue({ data: null }); // Simulate no data 180 | 181 | const result = await handleGetAuthorInfo( 182 | { author_key: authorKey }, 183 | mockAxiosInstance, 184 | ); 185 | 186 | expect(result.isError).toBeUndefined(); 187 | expect(result.content).toEqual([ 188 | { 189 | type: "text", 190 | text: `No data found for author key: "${authorKey}"`, 191 | }, 192 | ]); 193 | }); 194 | }); 195 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 4 | import { 5 | CallToolRequestSchema, 6 | ErrorCode, 7 | ListToolsRequestSchema, 8 | McpError, 9 | } from "@modelcontextprotocol/sdk/types.js"; 10 | import axios from "axios"; 11 | 12 | import { 13 | handleGetAuthorPhoto, 14 | handleGetBookByTitle, 15 | handleGetBookCover, 16 | handleGetAuthorsByName, 17 | handleGetAuthorInfo, 18 | handleGetBookById, 19 | } from "./tools/index.js"; 20 | 21 | class OpenLibraryServer { 22 | private server: Server; 23 | private axiosInstance; 24 | 25 | constructor() { 26 | this.server = new Server( 27 | { 28 | name: "open-library-server", 29 | version: "1.0.0", 30 | }, 31 | { 32 | capabilities: { 33 | resources: {}, 34 | tools: {}, 35 | }, 36 | }, 37 | ); 38 | 39 | this.axiosInstance = axios.create({ 40 | baseURL: "https://openlibrary.org", 41 | }); 42 | 43 | this.setupToolHandlers(); 44 | 45 | this.server.onerror = (error) => console.error("[MCP Error]", error); 46 | process.on("SIGINT", async () => { 47 | await this.server.close(); 48 | process.exit(0); 49 | }); 50 | } 51 | 52 | private setupToolHandlers() { 53 | this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ 54 | tools: [ 55 | { 56 | name: "get_book_by_title", 57 | description: "Search for a book by its title on Open Library.", 58 | inputSchema: { 59 | type: "object", 60 | properties: { 61 | title: { 62 | type: "string", 63 | description: "The title of the book to search for.", 64 | }, 65 | }, 66 | required: ["title"], 67 | }, 68 | }, 69 | { 70 | name: "get_authors_by_name", 71 | description: "Search for author information on Open Library.", 72 | inputSchema: { 73 | type: "object", 74 | properties: { 75 | name: { 76 | type: "string", 77 | description: "The name of the author to search for.", 78 | }, 79 | }, 80 | required: ["name"], 81 | }, 82 | }, 83 | { 84 | name: "get_author_info", 85 | description: 86 | "Get detailed information for a specific author using their Open Library Author Key (e.g. OL23919A).", 87 | inputSchema: { 88 | type: "object", 89 | properties: { 90 | author_key: { 91 | type: "string", 92 | description: 93 | "The Open Library key for the author (e.g., OL23919A).", 94 | }, 95 | }, 96 | required: ["author_key"], 97 | }, 98 | }, 99 | { 100 | name: "get_author_photo", 101 | description: 102 | "Get the URL for an author's photo using their Open Library Author ID (OLID e.g. OL23919A).", 103 | inputSchema: { 104 | type: "object", 105 | properties: { 106 | olid: { 107 | type: "string", 108 | description: 109 | "The Open Library Author ID (OLID) for the author (e.g. OL23919A).", 110 | }, 111 | }, 112 | required: ["olid"], 113 | }, 114 | }, 115 | { 116 | name: "get_book_cover", 117 | description: 118 | "Get the URL for a book's cover image using a key (ISBN, OCLC, LCCN, OLID, ID) and value.", 119 | inputSchema: { 120 | type: "object", 121 | properties: { 122 | key: { 123 | type: "string", 124 | // ID is internal cover ID 125 | enum: ["ISBN", "OCLC", "LCCN", "OLID", "ID"], 126 | description: 127 | "The type of identifier used (ISBN, OCLC, LCCN, OLID, ID).", 128 | }, 129 | value: { 130 | type: "string", 131 | description: "The value of the identifier.", 132 | }, 133 | size: { 134 | type: "string", 135 | enum: ["S", "M", "L"], 136 | description: "The desired size of the cover (S, M, or L).", 137 | }, 138 | }, 139 | required: ["key", "value"], 140 | }, 141 | }, 142 | { 143 | name: "get_book_by_id", 144 | description: 145 | "Get detailed information about a book using its identifier (ISBN, LCCN, OCLC, OLID).", 146 | inputSchema: { 147 | type: "object", 148 | properties: { 149 | idType: { 150 | type: "string", 151 | enum: ["isbn", "lccn", "oclc", "olid"], 152 | description: 153 | "The type of identifier used (ISBN, LCCN, OCLC, OLID).", 154 | }, 155 | idValue: { 156 | type: "string", 157 | description: "The value of the identifier.", 158 | }, 159 | }, 160 | required: ["idType", "idValue"], 161 | }, 162 | }, 163 | ], 164 | })); 165 | 166 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => { 167 | const { name, arguments: args } = request.params; 168 | 169 | switch (name) { 170 | case "get_book_by_title": 171 | return handleGetBookByTitle(args, this.axiosInstance); 172 | case "get_authors_by_name": 173 | return handleGetAuthorsByName(args, this.axiosInstance); 174 | case "get_author_info": 175 | return handleGetAuthorInfo(args, this.axiosInstance); 176 | case "get_author_photo": 177 | return handleGetAuthorPhoto(args); 178 | case "get_book_cover": 179 | return handleGetBookCover(args); 180 | case "get_book_by_id": 181 | return handleGetBookById(args, this.axiosInstance); 182 | default: 183 | throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); 184 | } 185 | }); 186 | } 187 | 188 | async run() { 189 | const transport = new StdioServerTransport(); 190 | await this.server.connect(transport); 191 | console.error("Open Library MCP server running on stdio"); 192 | } 193 | } 194 | 195 | if (process.argv[1] === new URL(import.meta.url).pathname) { 196 | const server = new OpenLibraryServer(); 197 | server.run().catch(console.error); 198 | } 199 | 200 | export { OpenLibraryServer }; 201 | -------------------------------------------------------------------------------- /src/tools/get-authors-by-name/index.test.ts: -------------------------------------------------------------------------------- 1 | import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; 2 | import { AxiosInstance, AxiosError, AxiosHeaders } from "axios"; 3 | import { describe, it, expect, vi, beforeEach } from "vitest"; 4 | 5 | import { OpenLibraryAuthorSearchResponse } from "./types.js"; 6 | 7 | import { handleGetAuthorsByName } from "./index.js"; 8 | 9 | // Mock axios module 10 | vi.mock("axios"); 11 | 12 | // Create a mock Axios instance 13 | const mockAxiosInstance = { 14 | get: vi.fn(), 15 | } as unknown as AxiosInstance; 16 | 17 | const mockedAxiosInstanceGet = vi.mocked(mockAxiosInstance.get); 18 | 19 | // Helper to create a minimal valid config 20 | const createMockConfig = () => ({ 21 | headers: new AxiosHeaders(), // Use AxiosHeaders 22 | url: "", 23 | method: "get", 24 | // Add other minimal required properties if necessary based on Axios types 25 | }); 26 | 27 | describe("handleGetAuthorsByName", () => { 28 | beforeEach(() => { 29 | vi.clearAllMocks(); 30 | }); 31 | 32 | it("should return author information when authors are found", async () => { 33 | const mockArgs = { name: "Tolkien" }; 34 | const mockApiResponse: OpenLibraryAuthorSearchResponse = { 35 | numFound: 1, 36 | start: 0, 37 | numFoundExact: true, 38 | docs: [ 39 | { 40 | key: "OL23 Tolkien", 41 | type: "author", 42 | name: "J.R.R. Tolkien", 43 | alternate_names: ["John Ronald Reuel Tolkien"], 44 | birth_date: "1892-01-03", 45 | top_work: "The Lord of the Rings", 46 | work_count: 100, 47 | top_subjects: ["fantasy", "fiction"], 48 | _version_: 12345, 49 | }, 50 | ], 51 | }; 52 | 53 | const mockConfig = createMockConfig(); 54 | const mockAxiosResponse = { 55 | data: mockApiResponse, 56 | status: 200, 57 | statusText: "OK", 58 | headers: { "content-type": "application/json" }, 59 | config: mockConfig, 60 | }; 61 | 62 | mockedAxiosInstanceGet.mockResolvedValue(mockAxiosResponse); 63 | 64 | const result = await handleGetAuthorsByName(mockArgs, mockAxiosInstance); 65 | 66 | expect(mockedAxiosInstanceGet).toHaveBeenCalledWith( 67 | "/search/authors.json", 68 | { params: { q: "Tolkien" } }, 69 | ); 70 | expect(result.isError).toBeUndefined(); 71 | expect(result.content).toEqual([ 72 | { 73 | type: "text", 74 | text: JSON.stringify( 75 | [ 76 | { 77 | key: "OL23 Tolkien", 78 | name: "J.R.R. Tolkien", 79 | alternate_names: ["John Ronald Reuel Tolkien"], 80 | birth_date: "1892-01-03", 81 | top_work: "The Lord of the Rings", 82 | work_count: 100, 83 | }, 84 | ], 85 | null, 86 | 2, 87 | ), 88 | }, 89 | ]); 90 | }); 91 | 92 | it("should return a message when no authors are found", async () => { 93 | const mockArgs = { name: "NonExistentAuthor" }; 94 | const mockApiResponse: OpenLibraryAuthorSearchResponse = { 95 | numFound: 0, 96 | start: 0, 97 | numFoundExact: true, 98 | docs: [], 99 | }; 100 | 101 | const mockConfig = createMockConfig(); 102 | const mockAxiosResponse = { 103 | data: mockApiResponse, 104 | status: 200, 105 | statusText: "OK", 106 | headers: { "content-type": "application/json" }, 107 | config: mockConfig, 108 | }; 109 | 110 | mockedAxiosInstanceGet.mockResolvedValue(mockAxiosResponse); 111 | 112 | const result = await handleGetAuthorsByName(mockArgs, mockAxiosInstance); 113 | 114 | expect(mockedAxiosInstanceGet).toHaveBeenCalledWith( 115 | "/search/authors.json", 116 | { params: { q: "NonExistentAuthor" } }, 117 | ); 118 | expect(result.isError).toBeUndefined(); 119 | expect(result.content).toEqual([ 120 | { 121 | type: "text", 122 | text: 'No authors found matching name: "NonExistentAuthor"', 123 | }, 124 | ]); 125 | }); 126 | 127 | it("should handle Axios errors with response", async () => { 128 | const mockArgs = { name: "ErrorCase" }; 129 | const mockConfig = createMockConfig(); 130 | const mockResponse = { 131 | data: null, 132 | status: 500, 133 | statusText: "Internal Server Error", 134 | headers: {}, 135 | config: mockConfig, 136 | }; 137 | 138 | const axiosError = new AxiosError( 139 | "Request failed with status code 500", 140 | "ERR_BAD_RESPONSE", 141 | mockConfig, 142 | null, 143 | mockResponse, 144 | ); 145 | 146 | mockedAxiosInstanceGet.mockRejectedValue(axiosError); 147 | 148 | const result = await handleGetAuthorsByName(mockArgs, mockAxiosInstance); 149 | 150 | expect(mockedAxiosInstanceGet).toHaveBeenCalledWith( 151 | "/search/authors.json", 152 | { params: { q: "ErrorCase" } }, 153 | ); 154 | expect(result.isError).toBe(true); 155 | expect(result.content).toEqual([ 156 | { 157 | type: "text", 158 | text: "Failed to fetch author data from Open Library.", 159 | }, 160 | ]); 161 | }); 162 | 163 | it("should handle Axios errors without response (e.g., network error)", async () => { 164 | const mockArgs = { name: "NetworkErrorCase" }; 165 | const mockConfig = createMockConfig(); 166 | 167 | const axiosError = new AxiosError( 168 | "Network Error", // Message 169 | "ECONNREFUSED", // Code 170 | mockConfig, // Config 171 | null, // Request 172 | undefined, 173 | ); 174 | 175 | mockedAxiosInstanceGet.mockRejectedValue(axiosError); 176 | 177 | const result = await handleGetAuthorsByName(mockArgs, mockAxiosInstance); 178 | 179 | expect(mockedAxiosInstanceGet).toHaveBeenCalledWith( 180 | "/search/authors.json", 181 | { params: { q: "NetworkErrorCase" } }, 182 | ); 183 | expect(result.isError).toBe(true); 184 | expect(result.content).toEqual([ 185 | { 186 | type: "text", 187 | text: "Failed to fetch author data from Open Library.", 188 | }, 189 | ]); 190 | }); 191 | 192 | it("should handle generic errors", async () => { 193 | const mockArgs = { name: "GenericError" }; 194 | const genericError = new Error("Something went wrong"); 195 | 196 | mockedAxiosInstanceGet.mockRejectedValue(genericError); 197 | 198 | const result = await handleGetAuthorsByName(mockArgs, mockAxiosInstance); 199 | 200 | expect(mockedAxiosInstanceGet).toHaveBeenCalledWith( 201 | "/search/authors.json", 202 | { params: { q: "GenericError" } }, 203 | ); 204 | expect(result.isError).toBe(true); 205 | expect(result.content).toEqual([ 206 | { 207 | type: "text", 208 | text: "Error processing request: Something went wrong", 209 | }, 210 | ]); 211 | }); 212 | 213 | it("should throw McpError for invalid arguments (empty name)", async () => { 214 | const mockArgs = { name: "" }; 215 | 216 | await expect( 217 | handleGetAuthorsByName(mockArgs, mockAxiosInstance), 218 | ).rejects.toThrow( 219 | new McpError( 220 | ErrorCode.InvalidParams, 221 | "Invalid arguments for get_authors_by_name: name: Author name cannot be empty", 222 | ), 223 | ); 224 | 225 | expect(mockedAxiosInstanceGet).not.toHaveBeenCalled(); 226 | }); 227 | 228 | it("should throw McpError for invalid arguments (missing name)", async () => { 229 | const mockArgs = {}; 230 | 231 | await expect( 232 | handleGetAuthorsByName(mockArgs, mockAxiosInstance), 233 | ).rejects.toThrow( 234 | new McpError( 235 | ErrorCode.InvalidParams, 236 | "Invalid arguments for get_authors_by_name: name: Required", 237 | ), 238 | ); 239 | 240 | expect(mockedAxiosInstanceGet).not.toHaveBeenCalled(); 241 | }); 242 | }); 243 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP Open Library 2 | 3 | [![Trust Score](https://archestra.ai/mcp-catalog/api/badge/quality/8enSmith/mcp-open-library)](https://archestra.ai/mcp-catalog/8ensmith__mcp-open-library) 4 | [![smithery badge](https://smithery.ai/badge/@8enSmith/mcp-open-library)](https://smithery.ai/server/@8enSmith/mcp-open-library) 5 | 6 | A Model Context Protocol (MCP) server for the Open Library API that enables AI assistants to search for book and author information. 7 | 8 | 9 | mcp-open-library MCP server 10 | 11 | 12 | ## Overview 13 | 14 | This project implements an MCP server that provides tools for AI assistants to interact with the [Open Library](https://openlibrary.org/). It allows searching for book information by title, searching for authors by name, retrieving detailed author information using their Open Library key, and getting URLs for author photos using their Open Library ID (OLID). The server returns structured data for book and author information. 15 | 16 | ## Features 17 | 18 | - **Book Search by Title**: Search for books using their title (`get_book_by_title`). 19 | - **Author Search by Name**: Search for authors using their name (`get_authors_by_name`). 20 | - **Get Author Details**: Retrieve detailed information for a specific author using their Open Library key (`get_author_info`). 21 | - **Get Author Photo**: Get the URL for an author's photo using their Open Library ID (OLID) (`get_author_photo`). 22 | - **Get Book Cover**: Get the URL for a book's cover image using various identifiers (ISBN, OCLC, LCCN, OLID, ID) (`get_book_cover`). 23 | - **Get Book by ID**: Retrieve detailed book information using various identifiers (ISBN, LCCN, OCLC, OLID) (`get_book_by_id`). 24 | 25 | ## Installation 26 | 27 | ### Installing via Smithery 28 | 29 | To install MCP Open Library for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@8enSmith/mcp-open-library): 30 | 31 | ```bash 32 | npx -y @smithery/cli install @8enSmith/mcp-open-library --client claude 33 | ``` 34 | 35 | ### Manual Installation 36 | ```bash 37 | # Clone the repository 38 | git clone https://github.com/8enSmith/mcp-open-library.git 39 | cd mcp-open-library 40 | 41 | # Install dependencies 42 | npm install 43 | 44 | # Build the project 45 | npm run build 46 | ``` 47 | 48 | ## Usage 49 | 50 | ### Running the Server 51 | 52 | 1. Ensure you are running node v22.21.1 (it'll probably work on a newer version of node but this is what Im using for this test). If you have `nvm` installed run `nvm use`. 53 | 2. In the `mcp-open-library` root directory run `npm run build` 54 | 3. Next run `npm run inspector`. Once built, click the URL with the `MCP_PROXY_AUTH_TOKEN` query string parameter to open the Inspector. 55 | 4. In the Inspector, choose 'STDIO' transport 56 | 5. Make sure the command is set to 'build/index.js' 57 | 6. Click the 'Connect' button in the Inspector - you'll now connect to the server 58 | 7. Click 'Tools' in the top right menu bar 59 | 8. Try running a tool e.g. click get_book_by_title 60 | 9. Search for a book e.g. In the title box enter 'The Hobbit' and then click 'Run Tool'. Server will then return book details. 61 | 62 | ### Using with an MCP Client 63 | 64 | This server implements the Model Context Protocol, which means it can be used by any MCP-compatible AI assistant or client e.g. [Claude Desktop](https://modelcontextprotocol.io/quickstart/user). The server exposes the following tools: 65 | 66 | - `get_book_by_title`: Search for book information by title 67 | - `get_authors_by_name`: Search for author information by name 68 | - `get_author_info`: Get detailed information for a specific author using their Open Library Author Key 69 | - `get_author_photo`: Get the URL for an author's photo using their Open Library Author ID (OLID) 70 | - `get_book_cover`: Get the URL for a book's cover image using a specific identifier (ISBN, OCLC, LCCN, OLID, or ID) 71 | - `get_book_by_id`: Get detailed book information using a specific identifier (ISBN, LCCN, OCLC, or OLID) 72 | 73 | **Example `get_book_by_title` input:** 74 | ```json 75 | { 76 | "title": "The Hobbit" 77 | } 78 | ``` 79 | 80 | **Example `get_book_by_title` output:** 81 | ```json 82 | [ 83 | { 84 | "title": "The Hobbit", 85 | "authors": [ 86 | "J. R. R. Tolkien" 87 | ], 88 | "first_publish_year": 1937, 89 | "open_library_work_key": "/works/OL45883W", 90 | "edition_count": 120, 91 | "cover_url": "https://covers.openlibrary.org/b/id/10581294-M.jpg" 92 | } 93 | ] 94 | ``` 95 | 96 | **Example `get_authors_by_name` input:** 97 | ```json 98 | { 99 | "name": "J.R.R. Tolkien" 100 | } 101 | ``` 102 | 103 | **Example `get_authors_by_name` output:** 104 | ```json 105 | [ 106 | { 107 | "key": "OL26320A", 108 | "name": "J. R. R. Tolkien", 109 | "alternate_names": [ 110 | "John Ronald Reuel Tolkien" 111 | ], 112 | "birth_date": "3 January 1892", 113 | "top_work": "The Hobbit", 114 | "work_count": 648 115 | } 116 | ] 117 | ``` 118 | 119 | **Example `get_author_info` input:** 120 | ```json 121 | { 122 | "author_key": "OL26320A" 123 | } 124 | ``` 125 | 126 | **Example `get_author_info` output:** 127 | ```json 128 | { 129 | "name": "J. R. R. Tolkien", 130 | "personal_name": "John Ronald Reuel Tolkien", 131 | "birth_date": "3 January 1892", 132 | "death_date": "2 September 1973", 133 | "bio": "John Ronald Reuel Tolkien (1892-1973) was a major scholar of the English language, specializing in Old and Middle English. He served as the Rawlinson and Bosworth Professor of Anglo-Saxon and later the Merton Professor of English Language and Literature at Oxford University.", 134 | "alternate_names": ["John Ronald Reuel Tolkien"], 135 | "photos": [6791763], 136 | "key": "/authors/OL26320A", 137 | "remote_ids": { 138 | "viaf": "95218067", 139 | "wikidata": "Q892" 140 | }, 141 | "revision": 43, 142 | "last_modified": { 143 | "type": "/type/datetime", 144 | "value": "2023-02-12T05:50:22.881" 145 | } 146 | } 147 | ``` 148 | 149 | **Example `get_author_photo` input:** 150 | ```json 151 | { 152 | "olid": "OL26320A" 153 | } 154 | ``` 155 | 156 | **Example `get_author_photo` output:** 157 | ```text 158 | https://covers.openlibrary.org/a/olid/OL26320A-L.jpg 159 | ``` 160 | 161 | **Example `get_book_cover` input:** 162 | ```json 163 | { 164 | "key": "ISBN", 165 | "value": "9780547928227", 166 | "size": "L" 167 | } 168 | ``` 169 | 170 | **Example `get_book_cover` output:** 171 | ```text 172 | https://covers.openlibrary.org/b/isbn/9780547928227-L.jpg 173 | ``` 174 | 175 | The `get_book_cover` tool accepts the following parameters: 176 | - `key`: The type of identifier (one of: `ISBN`, `OCLC`, `LCCN`, `OLID`, or `ID`) 177 | - `value`: The value of the identifier 178 | - `size`: Optional cover size (`S` for small, `M` for medium, `L` for large, defaults to `L`) 179 | 180 | **Example `get_book_by_id` input:** 181 | ```json 182 | { 183 | "idType": "isbn", 184 | "idValue": "9780547928227" 185 | } 186 | ``` 187 | 188 | **Example `get_book_by_id` output:** 189 | ```json 190 | { 191 | "title": "The Hobbit", 192 | "authors": [ 193 | "J. R. R. Tolkien" 194 | ], 195 | "publishers": [ 196 | "Houghton Mifflin Harcourt" 197 | ], 198 | "publish_date": "October 21, 2012", 199 | "number_of_pages": 300, 200 | "isbn_13": [ 201 | "9780547928227" 202 | ], 203 | "isbn_10": [ 204 | "054792822X" 205 | ], 206 | "oclc": [ 207 | "794607877" 208 | ], 209 | "olid": [ 210 | "OL25380781M" 211 | ], 212 | "open_library_edition_key": "/books/OL25380781M", 213 | "open_library_work_key": "/works/OL45883W", 214 | "cover_url": "https://covers.openlibrary.org/b/id/8231496-M.jpg", 215 | "info_url": "https://openlibrary.org/books/OL25380781M/The_Hobbit", 216 | "preview_url": "https://archive.org/details/hobbit00tolkien" 217 | } 218 | ``` 219 | 220 | The `get_book_by_id` tool accepts the following parameters: 221 | - `idType`: The type of identifier (one of: `isbn`, `lccn`, `oclc`, `olid`) 222 | - `idValue`: The value of the identifier 223 | 224 | An example of this tool being used in Claude Desktop can be see here: 225 | 226 | image 227 | 228 | ### Docker 229 | 230 | You can test this MCP server using Docker. To do this first run: 231 | 232 | ```bash 233 | docker build -t mcp-open-library . 234 | docker run -p 8080:8080 mcp-open-library 235 | ``` 236 | 237 | You can then test the server running within Docker via the inspector e.g. 238 | 239 | ```bash 240 | npm run inspector http://localhost:8080 241 | ``` 242 | 243 | ## Development 244 | 245 | ### Project Structure 246 | 247 | - `src/index.ts` - Main server implementation 248 | - `src/types.ts` - TypeScript type definitions 249 | - `src/index.test.ts` - Test suite 250 | 251 | ### Available Scripts 252 | 253 | - `npm run build` - Build the TypeScript code 254 | - `npm run watch` - Watch for changes and rebuild 255 | - `npm test` - Run the test suite 256 | - `npm run format` - Format code with Prettier 257 | - `npm run inspector` - Run the MCP Inspector against the server 258 | 259 | ### Running Tests 260 | 261 | ```bash 262 | npm test 263 | ``` 264 | 265 | ## Contributing 266 | 267 | Contributions are welcome! Please feel free to submit a pull request. 268 | 269 | ## Acknowledgments 270 | 271 | - [Open Library API](https://openlibrary.org/developers/api) 272 | - [Model Context Protocol](https://github.com/modelcontextprotocol/mcp) 273 | -------------------------------------------------------------------------------- /src/tools/get-book-by-id/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; 3 | import { describe, it, expect, beforeEach, vi, Mock } from "vitest"; // Use vitest imports 4 | 5 | import { OpenLibraryBookResponse } from "./types.js"; // Import necessary type 6 | 7 | import { handleGetBookById } from "./index.js"; 8 | 9 | // Mock axios using vitest 10 | vi.mock("axios"); 11 | 12 | // Create a mock Axios instance type using vitest Mock 13 | type MockAxiosInstance = { 14 | get: Mock; // Use Mock from vitest 15 | }; 16 | 17 | describe("handleGetBookById", () => { 18 | let mockAxiosInstance: MockAxiosInstance; 19 | 20 | beforeEach(() => { 21 | // Reset mocks before each test using vitest 22 | vi.clearAllMocks(); 23 | // Create a fresh mock instance for each test using vitest 24 | mockAxiosInstance = { 25 | get: vi.fn(), // Use vi.fn() 26 | }; 27 | }); 28 | 29 | it("should return book details when given a valid OLID", async () => { 30 | const mockArgs = { idType: "olid", idValue: "OL7353617M" }; 31 | const mockApiResponse: OpenLibraryBookResponse = { 32 | records: { 33 | "/books/OL7353617M": { 34 | recordURL: 35 | "https://openlibrary.org/books/OL7353617M/The_Lord_of_the_Rings", 36 | data: { 37 | title: "The Lord of the Rings", 38 | authors: [{ url: "/authors/OL216228A", name: "J.R.R. Tolkien" }], 39 | publish_date: "1954", 40 | identifiers: { 41 | openlibrary: ["OL7353617M"], 42 | isbn_10: ["061826027X"], 43 | }, 44 | number_of_pages: 1216, 45 | cover: { 46 | medium: "https://covers.openlibrary.org/b/id/8264411-M.jpg", 47 | }, 48 | key: "/books/OL7353617M", 49 | url: "https://openlibrary.org/books/OL7353617M/The_Lord_of_the_Rings", 50 | }, 51 | details: { 52 | info_url: 53 | "https://openlibrary.org/books/OL7353617M/The_Lord_of_the_Rings", 54 | bib_key: "OLID:OL7353617M", 55 | preview_url: "https://archive.org/details/lordofrings00tolk_1", 56 | thumbnail_url: "https://covers.openlibrary.org/b/id/8264411-S.jpg", 57 | details: { 58 | key: "/books/OL7353617M", 59 | works: [{ key: "/works/OL45804W" }], 60 | title: "The Lord of the Rings", 61 | authors: [{ url: "/authors/OL216228A", name: "J.R.R. Tolkien" }], 62 | publishers: [{ name: "Houghton Mifflin" }], 63 | publish_date: "1954", 64 | isbn_10: ["061826027X"], 65 | number_of_pages: 1216, 66 | }, 67 | preview: "restricted", 68 | }, 69 | }, 70 | }, 71 | items: [], // Add required items property 72 | }; 73 | 74 | mockAxiosInstance.get.mockResolvedValue({ data: mockApiResponse }); 75 | 76 | const result = await handleGetBookById(mockArgs, mockAxiosInstance as any); // Cast to any for simplicity 77 | 78 | expect(mockAxiosInstance.get).toHaveBeenCalledWith( 79 | "/api/volumes/brief/olid/OL7353617M.json", 80 | ); 81 | expect(result).toEqual({ 82 | content: [ 83 | { 84 | type: "text", 85 | text: expect.stringContaining('"title": "The Lord of the Rings"'), 86 | }, 87 | ], 88 | }); 89 | // Check specific fields in the parsed JSON 90 | const parsedResult = JSON.parse(result.content[0].text as string); 91 | expect(parsedResult).toHaveProperty("title", "The Lord of the Rings"); 92 | expect(parsedResult).toHaveProperty("authors", ["J.R.R. Tolkien"]); 93 | expect(parsedResult).toHaveProperty("publish_date", "1954"); 94 | expect(parsedResult).toHaveProperty("number_of_pages", 1216); 95 | expect(parsedResult).toHaveProperty("isbn_10", ["061826027X"]); // Should be array from details 96 | expect(parsedResult).toHaveProperty("olid", ["OL7353617M"]); // Should be array from identifiers 97 | expect(parsedResult).toHaveProperty( 98 | "open_library_edition_key", 99 | "/books/OL7353617M", 100 | ); 101 | expect(parsedResult).toHaveProperty( 102 | "open_library_work_key", 103 | "/works/OL45804W", 104 | ); 105 | expect(parsedResult).toHaveProperty( 106 | "cover_url", 107 | "https://covers.openlibrary.org/b/id/8264411-M.jpg", 108 | ); 109 | expect(parsedResult).toHaveProperty( 110 | "info_url", 111 | "https://openlibrary.org/books/OL7353617M/The_Lord_of_the_Rings", 112 | ); 113 | expect(parsedResult).toHaveProperty( 114 | "preview_url", 115 | "https://archive.org/details/lordofrings00tolk_1", 116 | ); 117 | }); 118 | 119 | it("should return book details when given a valid ISBN", async () => { 120 | const mockArgs = { idType: "isbn", idValue: "9780547928227" }; 121 | const mockApiResponse: OpenLibraryBookResponse = { 122 | records: { 123 | "isbn:9780547928227": { 124 | recordURL: "https://openlibrary.org/books/OL25189068M/The_Hobbit", 125 | data: { 126 | title: "The Hobbit", 127 | authors: [{ url: "/authors/OL216228A", name: "J.R.R. Tolkien" }], 128 | publish_date: "2012", 129 | identifiers: { 130 | isbn_13: ["9780547928227"], 131 | openlibrary: ["OL25189068M"], 132 | }, 133 | key: "/books/OL25189068M", 134 | url: "https://openlibrary.org/books/OL25189068M/The_Hobbit", 135 | }, 136 | details: { 137 | /* ... potentially more details ... */ 138 | } as any, // Cast for brevity 139 | }, 140 | }, 141 | items: [], // Add required items property 142 | }; 143 | mockAxiosInstance.get.mockResolvedValue({ data: mockApiResponse }); 144 | 145 | const result = await handleGetBookById(mockArgs, mockAxiosInstance as any); 146 | 147 | expect(mockAxiosInstance.get).toHaveBeenCalledWith( 148 | "/api/volumes/brief/isbn/9780547928227.json", 149 | ); 150 | expect(result).toEqual({ 151 | content: [ 152 | { 153 | type: "text", 154 | text: expect.stringContaining('"title": "The Hobbit"'), 155 | }, 156 | ], 157 | }); 158 | const parsedResult = JSON.parse(result.content[0].text as string); 159 | expect(parsedResult).toHaveProperty("title", "The Hobbit"); 160 | expect(parsedResult).toHaveProperty("isbn_13", ["9780547928227"]); 161 | expect(parsedResult).toHaveProperty("olid", ["OL25189068M"]); 162 | }); 163 | 164 | it("should throw McpError for invalid arguments", async () => { 165 | const invalidArgs = { idType: "invalid", idValue: "123" }; // Invalid idType 166 | 167 | await expect( 168 | handleGetBookById(invalidArgs, mockAxiosInstance as any), 169 | ).rejects.toThrow(McpError); 170 | 171 | try { 172 | await handleGetBookById(invalidArgs, mockAxiosInstance as any); 173 | } catch (error) { 174 | expect(error).toBeInstanceOf(McpError); 175 | expect((error as McpError).code).toBe(ErrorCode.InvalidParams); 176 | expect((error as McpError).message).toContain( 177 | "Invalid arguments for get_book_by_id", 178 | ); 179 | expect((error as McpError).message).toContain( 180 | "idType must be one of: isbn, lccn, oclc, olid", 181 | ); 182 | } 183 | expect(mockAxiosInstance.get).not.toHaveBeenCalled(); 184 | }); 185 | 186 | it('should return "No book found" message when API returns empty records', async () => { 187 | const mockArgs = { idType: "olid", idValue: "OL_NONEXISTENT" }; 188 | const mockApiResponse: OpenLibraryBookResponse = { 189 | records: {}, 190 | items: [], 191 | }; // Empty records 192 | 193 | mockAxiosInstance.get.mockResolvedValue({ data: mockApiResponse }); 194 | 195 | const result = await handleGetBookById(mockArgs, mockAxiosInstance as any); 196 | 197 | expect(mockAxiosInstance.get).toHaveBeenCalledWith( 198 | "/api/volumes/brief/olid/OL_NONEXISTENT.json", 199 | ); 200 | expect(result).toEqual({ 201 | content: [ 202 | { 203 | type: "text", 204 | text: "No book found for olid: OL_NONEXISTENT", 205 | }, 206 | ], 207 | }); 208 | }); 209 | 210 | it('should return "No book found" message on 404 API error', async () => { 211 | const mockArgs = { idType: "isbn", idValue: "0000000000" }; 212 | const axiosError = { 213 | isAxiosError: true, 214 | response: { status: 404, statusText: "Not Found" }, 215 | message: "Request failed with status code 404", 216 | }; 217 | mockAxiosInstance.get.mockRejectedValue(axiosError); 218 | 219 | const result = await handleGetBookById(mockArgs, mockAxiosInstance as any); 220 | 221 | expect(mockAxiosInstance.get).toHaveBeenCalledWith( 222 | "/api/volumes/brief/isbn/0000000000.json", 223 | ); 224 | expect(result).toEqual({ 225 | content: [ 226 | { 227 | type: "text", 228 | text: "Failed to fetch book data from Open Library.", // Specific message for 404 229 | }, 230 | ], 231 | }); 232 | }); 233 | 234 | it("should return generic API error message for non-404 errors", async () => { 235 | const mockArgs = { idType: "olid", idValue: "OL1M" }; 236 | const axiosError = { 237 | isAxiosError: true, 238 | response: { status: 500, statusText: "Internal Server Error" }, 239 | message: "Request failed with status code 500", 240 | }; 241 | mockAxiosInstance.get.mockRejectedValue(axiosError); 242 | 243 | const result = await handleGetBookById(mockArgs, mockAxiosInstance as any); 244 | 245 | expect(mockAxiosInstance.get).toHaveBeenCalledWith( 246 | "/api/volumes/brief/olid/OL1M.json", 247 | ); 248 | expect(result).toEqual({ 249 | content: [ 250 | { 251 | type: "text", 252 | text: "Failed to fetch book data from Open Library.", // Generic API error 253 | }, 254 | ], 255 | }); 256 | }); 257 | 258 | it("should return generic error message for non-Axios errors", async () => { 259 | const mockArgs = { idType: "olid", idValue: "OL1M" }; 260 | const genericError = new Error("Network Failure"); 261 | mockAxiosInstance.get.mockRejectedValue(genericError); 262 | 263 | const result = await handleGetBookById(mockArgs, mockAxiosInstance as any); 264 | 265 | expect(mockAxiosInstance.get).toHaveBeenCalledWith( 266 | "/api/volumes/brief/olid/OL1M.json", 267 | ); 268 | expect(result).toEqual({ 269 | content: [ 270 | { 271 | type: "text", 272 | text: "Error processing request: Network Failure", // Generic processing error 273 | }, 274 | ], 275 | }); 276 | }); 277 | }); 278 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 3 | import { 4 | CallToolRequestSchema, 5 | ListToolsRequestSchema, 6 | McpError, 7 | ErrorCode, 8 | } from "@modelcontextprotocol/sdk/types.js"; 9 | import axios from "axios"; 10 | import { describe, it, expect, vi, beforeEach } from "vitest"; 11 | import { Mock } from "vitest"; 12 | 13 | import { OpenLibraryServer } from "./index.js"; 14 | // Mock the MCP Server and its methods 15 | vi.mock("@modelcontextprotocol/sdk/server/index.js", () => { 16 | const mockServer = { 17 | setRequestHandler: vi.fn(), 18 | connect: vi.fn().mockResolvedValue(undefined), 19 | close: vi.fn().mockResolvedValue(undefined), 20 | onerror: vi.fn(), 21 | }; 22 | return { 23 | Server: vi.fn(() => mockServer), 24 | }; 25 | }); 26 | 27 | // Mock axios 28 | vi.mock("axios"); 29 | const mockedAxios = vi.mocked(axios, true); // Use true for deep mocking 30 | 31 | describe("OpenLibraryServer", () => { 32 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 33 | let serverInstance: OpenLibraryServer; 34 | // Explicitly type the mock server instance based on the mocked structure 35 | let mockMcpServer: { 36 | setRequestHandler: Mock< 37 | (schema: any, handler: (...args: any[]) => Promise) => void 38 | >; 39 | connect: Mock<(transport: any) => Promise>; 40 | close: Mock<() => Promise>; 41 | onerror: Mock<(error: any) => void>; 42 | }; 43 | 44 | beforeEach(() => { 45 | // Reset mocks before each test 46 | vi.clearAllMocks(); 47 | // Create a new instance, which will internally create a mocked Server 48 | serverInstance = new OpenLibraryServer(); 49 | // Get the mocked MCP Server instance created by the constructor 50 | mockMcpServer = (Server as any).mock.results[0].value; 51 | mockedAxios.create.mockReturnThis(); // Ensure axios.create() returns the mocked instance 52 | }); 53 | 54 | describe("get_book_by_title tool", () => { 55 | it("should correctly list the get_book_by_title tool", async () => { 56 | // Find the handler registered for ListToolsRequestSchema 57 | const listToolsHandler = mockMcpServer.setRequestHandler.mock.calls.find( 58 | (call: [any, (...args: any[]) => Promise]) => 59 | call[0] === ListToolsRequestSchema, 60 | )?.[1]; 61 | 62 | expect(listToolsHandler).toBeDefined(); 63 | 64 | if (listToolsHandler) { 65 | const result = await listToolsHandler({} as any); // Call the handler 66 | expect(result.tools).toHaveLength(6); 67 | expect(result.tools[0].name).toBe("get_book_by_title"); 68 | expect(result.tools[0].description).toBeDefined(); 69 | expect(result.tools[0].inputSchema).toEqual({ 70 | type: "object", 71 | properties: { 72 | title: { 73 | type: "string", 74 | description: "The title of the book to search for.", 75 | }, 76 | }, 77 | required: ["title"], 78 | }); 79 | } 80 | }); 81 | }); 82 | 83 | describe("get_authors_by_name tool", () => { 84 | it("should correctly list the get_authors_by_name tool", async () => { 85 | const listToolsHandler = mockMcpServer.setRequestHandler.mock.calls.find( 86 | (call: [any, (...args: any[]) => Promise]) => 87 | call[0] === ListToolsRequestSchema, 88 | )?.[1]; 89 | 90 | expect(listToolsHandler).toBeDefined(); 91 | 92 | if (listToolsHandler) { 93 | const result = await listToolsHandler({} as any); 94 | expect(result.tools).toHaveLength(6); 95 | const authorTool = result.tools.find( 96 | (tool: any) => tool.name === "get_authors_by_name", 97 | ); 98 | expect(authorTool).toBeDefined(); 99 | expect(authorTool.description).toBeDefined(); 100 | expect(authorTool.inputSchema).toEqual({ 101 | type: "object", 102 | properties: { 103 | name: { 104 | type: "string", 105 | description: "The name of the author to search for.", 106 | }, 107 | }, 108 | required: ["name"], 109 | }); 110 | } 111 | }); 112 | 113 | it("should handle CallTool request for get_authors_by_name successfully", async () => { 114 | const callToolHandler = mockMcpServer.setRequestHandler.mock.calls.find( 115 | (call: [any, (...args: any[]) => Promise]) => 116 | call[0] === CallToolRequestSchema, 117 | )?.[1]; 118 | 119 | expect(callToolHandler).toBeDefined(); 120 | 121 | if (callToolHandler) { 122 | const mockApiResponse = { 123 | data: { 124 | docs: [ 125 | { 126 | key: "OL23919A", 127 | name: "J. R. R. Tolkien", 128 | alternate_names: ["John Ronald Reuel Tolkien"], 129 | birth_date: "3 January 1892", 130 | top_work: "The Lord of the Rings", 131 | work_count: 150, 132 | }, 133 | ], 134 | }, 135 | }; 136 | mockedAxios.get.mockResolvedValue(mockApiResponse); 137 | 138 | const mockRequest = { 139 | params: { 140 | name: "get_authors_by_name", 141 | arguments: { name: "J. R. R. Tolkien" }, 142 | }, 143 | }; 144 | 145 | const result = await callToolHandler(mockRequest as any); 146 | 147 | expect(mockedAxios.get).toHaveBeenCalledWith("/search/authors.json", { 148 | params: { q: "J. R. R. Tolkien" }, 149 | }); 150 | expect(result.isError).toBeUndefined(); 151 | expect(result.content).toHaveLength(1); 152 | expect(result.content[0].type).toBe("text"); 153 | const expectedAuthorInfo = [ 154 | { 155 | key: "OL23919A", 156 | name: "J. R. R. Tolkien", 157 | alternate_names: ["John Ronald Reuel Tolkien"], 158 | birth_date: "3 January 1892", 159 | top_work: "The Lord of the Rings", 160 | work_count: 150, 161 | }, 162 | ]; 163 | expect(JSON.parse(result.content[0].text)).toEqual(expectedAuthorInfo); 164 | } 165 | }); 166 | }); 167 | 168 | describe("get_author_info tool", () => { 169 | it("should correctly list the get_author_info tool", async () => { 170 | const listToolsHandler = mockMcpServer.setRequestHandler.mock.calls.find( 171 | (call: [any, (...args: any[]) => Promise]) => 172 | call[0] === ListToolsRequestSchema, 173 | )?.[1]; 174 | 175 | expect(listToolsHandler).toBeDefined(); 176 | 177 | if (listToolsHandler) { 178 | const result = await listToolsHandler({} as any); 179 | expect(result.tools).toHaveLength(6); 180 | const authorInfoTool = result.tools.find( 181 | (tool: any) => tool.name === "get_author_info", 182 | ); 183 | expect(authorInfoTool).toBeDefined(); 184 | expect(authorInfoTool.description).toBeDefined(); 185 | expect(authorInfoTool.inputSchema).toEqual({ 186 | type: "object", 187 | properties: { 188 | author_key: { 189 | type: "string", 190 | description: 191 | "The Open Library key for the author (e.g., OL23919A).", 192 | }, 193 | }, 194 | required: ["author_key"], 195 | }); 196 | } 197 | }); 198 | 199 | it("should handle CallTool request for get_author_info successfully", async () => { 200 | const callToolHandler = mockMcpServer.setRequestHandler.mock.calls.find( 201 | (call: [any, (...args: any[]) => Promise]) => 202 | call[0] === CallToolRequestSchema, 203 | )?.[1]; 204 | 205 | expect(callToolHandler).toBeDefined(); 206 | 207 | if (callToolHandler) { 208 | const mockApiResponse = { 209 | data: { 210 | key: "/authors/OL23919A", 211 | name: "J. R. R. Tolkien", 212 | birth_date: "3 January 1892", 213 | death_date: "2 September 1973", 214 | bio: "British writer, poet, philologist, and university professor", 215 | photos: [12345], 216 | }, 217 | }; 218 | mockedAxios.get.mockResolvedValue(mockApiResponse); 219 | 220 | const mockRequest = { 221 | params: { 222 | name: "get_author_info", 223 | arguments: { author_key: "OL23919A" }, 224 | }, 225 | }; 226 | 227 | const result = await callToolHandler(mockRequest as any); 228 | 229 | expect(mockedAxios.get).toHaveBeenCalledWith("/authors/OL23919A.json"); 230 | expect(result.isError).toBeUndefined(); 231 | expect(result.content).toHaveLength(1); 232 | expect(result.content[0].type).toBe("text"); 233 | expect(JSON.parse(result.content[0].text)).toEqual( 234 | mockApiResponse.data, 235 | ); 236 | } 237 | }); 238 | }); 239 | 240 | describe("get_author_photo tool", () => { 241 | it("should correctly list the get_author_photo tool", async () => { 242 | const listToolsHandler = mockMcpServer.setRequestHandler.mock.calls.find( 243 | (call: [any, (...args: any[]) => Promise]) => 244 | call[0] === ListToolsRequestSchema, 245 | )?.[1]; 246 | 247 | expect(listToolsHandler).toBeDefined(); 248 | 249 | if (listToolsHandler) { 250 | const result = await listToolsHandler({} as any); 251 | expect(result.tools).toHaveLength(6); 252 | const authorPhotoTool = result.tools.find( 253 | (tool: any) => tool.name === "get_author_photo", 254 | ); 255 | expect(authorPhotoTool).toBeDefined(); 256 | expect(authorPhotoTool.description).toBeDefined(); 257 | expect(authorPhotoTool.inputSchema).toEqual({ 258 | type: "object", 259 | properties: { 260 | olid: { 261 | type: "string", 262 | description: 263 | "The Open Library Author ID (OLID) for the author (e.g. OL23919A).", 264 | }, 265 | }, 266 | required: ["olid"], 267 | }); 268 | } 269 | }); 270 | 271 | it("should handle CallTool request for get_author_photo successfully", async () => { 272 | const callToolHandler = mockMcpServer.setRequestHandler.mock.calls.find( 273 | (call: [any, (...args: any[]) => Promise]) => 274 | call[0] === CallToolRequestSchema, 275 | )?.[1]; 276 | 277 | expect(callToolHandler).toBeDefined(); 278 | 279 | if (callToolHandler) { 280 | const mockRequest = { 281 | params: { 282 | name: "get_author_photo", 283 | arguments: { olid: "OL23919A" }, 284 | }, 285 | }; 286 | 287 | const result = await callToolHandler(mockRequest as any); 288 | 289 | expect(mockedAxios.get).not.toHaveBeenCalled(); // No API call expected 290 | expect(result.isError).toBeUndefined(); 291 | expect(result.content).toHaveLength(1); 292 | expect(result.content[0].type).toBe("text"); 293 | expect(result.content[0].text).toBe( 294 | "https://covers.openlibrary.org/a/olid/OL23919A-L.jpg", 295 | ); 296 | } 297 | }); 298 | }); 299 | 300 | describe("get_book_cover tool", () => { 301 | it("should correctly list the get_book_cover tool", async () => { 302 | const listToolsHandler = mockMcpServer.setRequestHandler.mock.calls.find( 303 | (call: [any, (...args: any[]) => Promise]) => 304 | call[0] === ListToolsRequestSchema, 305 | )?.[1]; 306 | 307 | expect(listToolsHandler).toBeDefined(); 308 | 309 | if (listToolsHandler) { 310 | const result = await listToolsHandler({} as any); 311 | expect(result.tools.length).toBeGreaterThanOrEqual(5); 312 | const bookCoverTool = result.tools.find( 313 | (tool: any) => tool.name === "get_book_cover", 314 | ); 315 | expect(bookCoverTool).toBeDefined(); 316 | expect(bookCoverTool.description).toBeDefined(); 317 | expect(bookCoverTool.inputSchema).toEqual({ 318 | type: "object", 319 | properties: { 320 | key: { 321 | type: "string", 322 | enum: ["ISBN", "OCLC", "LCCN", "OLID", "ID"], 323 | description: 324 | "The type of identifier used (ISBN, OCLC, LCCN, OLID, ID).", 325 | }, 326 | value: { 327 | type: "string", 328 | description: "The value of the identifier.", 329 | }, 330 | size: { 331 | type: "string", 332 | enum: ["S", "M", "L"], 333 | description: "The desired size of the cover (S, M, or L).", 334 | }, 335 | }, 336 | required: ["key", "value"], 337 | }); 338 | } 339 | }); 340 | }); 341 | 342 | it("should handle CallTool request for an unknown tool", async () => { 343 | const callToolHandler = mockMcpServer.setRequestHandler.mock.calls.find( 344 | (call: [any, (...args: any[]) => Promise]) => 345 | call[0] === CallToolRequestSchema, 346 | )?.[1]; 347 | 348 | expect(callToolHandler).toBeDefined(); 349 | 350 | if (callToolHandler) { 351 | const mockRequest = { 352 | params: { 353 | name: "unknown_tool", 354 | arguments: { title: "The Hobbit" }, // Args don't matter here 355 | }, 356 | }; 357 | 358 | await expect(callToolHandler(mockRequest as any)).rejects.toThrow( 359 | new McpError(ErrorCode.MethodNotFound, "Unknown tool: unknown_tool"), 360 | ); 361 | expect(mockedAxios.get).not.toHaveBeenCalled(); 362 | } 363 | }); 364 | }); 365 | --------------------------------------------------------------------------------