diff --git a/package-lock.json b/package-lock.json index 2def776..b47548a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -136,7 +136,8 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.4.0", @@ -293,7 +294,8 @@ "version": "4.20250607.0", "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250607.0.tgz", "integrity": "sha512-OYmKNzC2eQy6CNj+j0go8Ut3SezjsprCgJyEaBzJql+473WAN9ndVnNZy9lj/tTyLV6wzpQkZWmRAKGDmacvkg==", - "license": "MIT OR Apache-2.0" + "license": "MIT OR Apache-2.0", + "peer": true }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -1782,6 +1784,7 @@ "integrity": "sha512-qwxv6dq682yVvgKKp2qWwLgRbscDAYktPptK4JPojCwwi3R9cwrvIxS4lvBpzmcqzR4bdn54Z0IG1uHFskW4dA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.33.1", "@typescript-eslint/types": "8.33.1", @@ -2058,6 +2061,7 @@ "integrity": "sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.0.9", "pathe": "^2.0.3" @@ -2072,6 +2076,7 @@ "integrity": "sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/pretty-format": "3.0.9", "magic-string": "^0.30.17", @@ -2154,6 +2159,7 @@ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2950,6 +2956,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3242,6 +3249,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4545,6 +4553,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5374,6 +5383,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5408,6 +5418,7 @@ "integrity": "sha512-B06u0wXkEd+o5gOCMl/ZHl5cfpYbDZKAT+HWTL+Hws6jWu7dCiqBBXXXzMFcFVJb8D4ytAnYmxJA83uwOQRSsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.4", @@ -5450,6 +5461,7 @@ "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -5548,6 +5560,7 @@ "integrity": "sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "3.0.9", "@vitest/mocker": "3.0.9", @@ -5661,6 +5674,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -6303,6 +6317,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/clients/cachedDiscogs.ts b/src/clients/cachedDiscogs.ts index 025c130..af8106e 100644 --- a/src/clients/cachedDiscogs.ts +++ b/src/clients/cachedDiscogs.ts @@ -16,6 +16,8 @@ import { type DiscogsCollectionStats, type DiscogsSearchResponse, type DiscogsCollectionItem, + type DiscogsFolder, + type DiscogsCustomField, } from './discogs' import { SmartCache, CacheKeys, createDiscogsCache } from '../utils/cache' @@ -429,6 +431,128 @@ export class CachedDiscogsClient { return stats } + // ────────────────────────────────────────────── + // Collection write operations (pass-through with cache invalidation) + // ────────────────────────────────────────────── + + async listFolders( + username: string, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + return this.client.listFolders(username, accessToken, accessTokenSecret, consumerKey, consumerSecret) + } + + async createFolder( + username: string, + name: string, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + const result = await this.client.createFolder(username, name, accessToken, accessTokenSecret, consumerKey, consumerSecret) + await this.invalidateUserCache(username) + return result + } + + async editFolder( + username: string, + folderId: number, + name: string, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + const result = await this.client.editFolder(username, folderId, name, accessToken, accessTokenSecret, consumerKey, consumerSecret) + await this.invalidateUserCache(username) + return result + } + + async deleteFolder( + username: string, + folderId: number, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + await this.client.deleteFolder(username, folderId, accessToken, accessTokenSecret, consumerKey, consumerSecret) + await this.invalidateUserCache(username) + } + + async addToFolder( + username: string, + folderId: number, + releaseId: number, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise<{ instance_id: number; resource_url: string }> { + const result = await this.client.addToFolder(username, folderId, releaseId, accessToken, accessTokenSecret, consumerKey, consumerSecret) + await this.invalidateUserCache(username) + return result + } + + async removeFromFolder( + username: string, + folderId: number, + releaseId: number, + instanceId: number, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + await this.client.removeFromFolder(username, folderId, releaseId, instanceId, accessToken, accessTokenSecret, consumerKey, consumerSecret) + await this.invalidateUserCache(username) + } + + async editInstance( + username: string, + folderId: number, + releaseId: number, + instanceId: number, + changes: { folder_id?: number; rating?: number }, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + await this.client.editInstance(username, folderId, releaseId, instanceId, changes, accessToken, accessTokenSecret, consumerKey, consumerSecret) + await this.invalidateUserCache(username) + } + + async listCustomFields( + username: string, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + return this.client.listCustomFields(username, accessToken, accessTokenSecret, consumerKey, consumerSecret) + } + + async editCustomFieldValue( + username: string, + folderId: number, + releaseId: number, + instanceId: number, + fieldId: number, + value: string, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + await this.client.editCustomFieldValue(username, folderId, releaseId, instanceId, fieldId, value, accessToken, accessTokenSecret, consumerKey, consumerSecret) + // No cache invalidation needed — custom fields don't affect collection structure + } + /** * Cleanup old cache entries */ diff --git a/src/clients/discogs.ts b/src/clients/discogs.ts index 1fc7ef6..5028c16 100644 --- a/src/clients/discogs.ts +++ b/src/clients/discogs.ts @@ -117,6 +117,23 @@ export interface DiscogsSearchResponse { }> } +export interface DiscogsFolder { + id: number + name: string + count: number + resource_url: string +} + +export interface DiscogsCustomField { + id: number + name: string + type: string // 'textarea' | 'dropdown' + public: boolean + position: number + options?: string[] // for dropdown fields + lines?: number // for textarea fields +} + export interface DiscogsCollectionStats { totalReleases: number totalValue: number @@ -913,6 +930,352 @@ export class DiscogsClient { throw new Error(`Failed to search database: ${error instanceof Error ? error.message : 'Unknown error'}`) } } + + // ────────────────────────────────────────────── + // Collection write operations + // ────────────────────────────────────────────── + + /** + * List all collection folders for a user + */ + async listFolders( + username: string, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + const url = `${this.baseUrl}/users/${username}/collection/folders` + const authHeader = await this.createOAuthHeader(url, 'GET', accessToken, accessTokenSecret, consumerKey, consumerSecret) + + try { + await this.throttleRequest() + const response = await fetchWithRetry( + url, + { + headers: { + Authorization: authHeader, + 'User-Agent': this.userAgent, + }, + }, + this.discogsRetryOptions, + ) + + const data: { folders: DiscogsFolder[] } = await response.json() + return data.folders + } catch (error) { + if (error instanceof Error && error.message.includes('429')) { + throw new Error('Discogs API rate limit exceeded for listing folders. Please try again later.') + } + throw new Error(`Failed to list folders: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Create a new collection folder + */ + async createFolder( + username: string, + name: string, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + const url = `${this.baseUrl}/users/${username}/collection/folders` + const authHeader = await this.createOAuthHeader(url, 'POST', accessToken, accessTokenSecret, consumerKey, consumerSecret) + + try { + await this.throttleRequest() + const response = await fetchWithRetry( + url, + { + method: 'POST', + headers: { + Authorization: authHeader, + 'User-Agent': this.userAgent, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }), + }, + this.discogsRetryOptions, + ) + + return response.json() + } catch (error) { + if (error instanceof Error && error.message.includes('429')) { + throw new Error('Discogs API rate limit exceeded. Please try again later.') + } + throw new Error(`Failed to create folder: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Edit (rename) a collection folder + */ + async editFolder( + username: string, + folderId: number, + name: string, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + const url = `${this.baseUrl}/users/${username}/collection/folders/${folderId}` + const authHeader = await this.createOAuthHeader(url, 'POST', accessToken, accessTokenSecret, consumerKey, consumerSecret) + + try { + await this.throttleRequest() + const response = await fetchWithRetry( + url, + { + method: 'POST', + headers: { + Authorization: authHeader, + 'User-Agent': this.userAgent, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }), + }, + this.discogsRetryOptions, + ) + + return response.json() + } catch (error) { + if (error instanceof Error && error.message.includes('429')) { + throw new Error('Discogs API rate limit exceeded. Please try again later.') + } + throw new Error(`Failed to edit folder: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Delete a collection folder (must be empty) + */ + async deleteFolder( + username: string, + folderId: number, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + const url = `${this.baseUrl}/users/${username}/collection/folders/${folderId}` + const authHeader = await this.createOAuthHeader(url, 'DELETE', accessToken, accessTokenSecret, consumerKey, consumerSecret) + + try { + await this.throttleRequest() + await fetchWithRetry( + url, + { + method: 'DELETE', + headers: { + Authorization: authHeader, + 'User-Agent': this.userAgent, + }, + }, + this.discogsRetryOptions, + ) + } catch (error) { + if (error instanceof Error && error.message.includes('429')) { + throw new Error('Discogs API rate limit exceeded. Please try again later.') + } + throw new Error(`Failed to delete folder: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Add a release to a collection folder + */ + async addToFolder( + username: string, + folderId: number, + releaseId: number, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise<{ instance_id: number; resource_url: string }> { + const url = `${this.baseUrl}/users/${username}/collection/folders/${folderId}/releases/${releaseId}` + const authHeader = await this.createOAuthHeader(url, 'POST', accessToken, accessTokenSecret, consumerKey, consumerSecret) + + try { + await this.throttleRequest() + const response = await fetchWithRetry( + url, + { + method: 'POST', + headers: { + Authorization: authHeader, + 'User-Agent': this.userAgent, + }, + }, + this.discogsRetryOptions, + ) + + return response.json() + } catch (error) { + if (error instanceof Error && error.message.includes('429')) { + throw new Error('Discogs API rate limit exceeded. Please try again later.') + } + throw new Error(`Failed to add release to folder: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Remove a release instance from a collection folder + */ + async removeFromFolder( + username: string, + folderId: number, + releaseId: number, + instanceId: number, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + const url = `${this.baseUrl}/users/${username}/collection/folders/${folderId}/releases/${releaseId}/instances/${instanceId}` + const authHeader = await this.createOAuthHeader(url, 'DELETE', accessToken, accessTokenSecret, consumerKey, consumerSecret) + + try { + await this.throttleRequest() + await fetchWithRetry( + url, + { + method: 'DELETE', + headers: { + Authorization: authHeader, + 'User-Agent': this.userAgent, + }, + }, + this.discogsRetryOptions, + ) + } catch (error) { + if (error instanceof Error && error.message.includes('429')) { + throw new Error('Discogs API rate limit exceeded. Please try again later.') + } + throw new Error(`Failed to remove release from folder: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Edit a collection instance (move to folder and/or change rating) + */ + async editInstance( + username: string, + folderId: number, + releaseId: number, + instanceId: number, + changes: { folder_id?: number; rating?: number }, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + const url = `${this.baseUrl}/users/${username}/collection/folders/${folderId}/releases/${releaseId}/instances/${instanceId}` + const authHeader = await this.createOAuthHeader(url, 'POST', accessToken, accessTokenSecret, consumerKey, consumerSecret) + + try { + await this.throttleRequest() + await fetchWithRetry( + url, + { + method: 'POST', + headers: { + Authorization: authHeader, + 'User-Agent': this.userAgent, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(changes), + }, + this.discogsRetryOptions, + ) + } catch (error) { + if (error instanceof Error && error.message.includes('429')) { + throw new Error('Discogs API rate limit exceeded. Please try again later.') + } + throw new Error(`Failed to edit instance: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * List custom fields for a user's collection + */ + async listCustomFields( + username: string, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + const url = `${this.baseUrl}/users/${username}/collection/fields` + const authHeader = await this.createOAuthHeader(url, 'GET', accessToken, accessTokenSecret, consumerKey, consumerSecret) + + try { + await this.throttleRequest() + const response = await fetchWithRetry( + url, + { + headers: { + Authorization: authHeader, + 'User-Agent': this.userAgent, + }, + }, + this.discogsRetryOptions, + ) + + const data: { fields: DiscogsCustomField[] } = await response.json() + return data.fields + } catch (error) { + if (error instanceof Error && error.message.includes('429')) { + throw new Error('Discogs API rate limit exceeded. Please try again later.') + } + throw new Error(`Failed to list custom fields: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Edit a custom field value on a collection instance + */ + async editCustomFieldValue( + username: string, + folderId: number, + releaseId: number, + instanceId: number, + fieldId: number, + value: string, + accessToken: string, + accessTokenSecret: string, + consumerKey: string, + consumerSecret: string, + ): Promise { + const url = `${this.baseUrl}/users/${username}/collection/folders/${folderId}/releases/${releaseId}/instances/${instanceId}/fields/${fieldId}` + const authHeader = await this.createOAuthHeader(url, 'POST', accessToken, accessTokenSecret, consumerKey, consumerSecret) + + try { + await this.throttleRequest() + await fetchWithRetry( + url, + { + method: 'POST', + headers: { + Authorization: authHeader, + 'User-Agent': this.userAgent, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ value }), + }, + this.discogsRetryOptions, + ) + } catch (error) { + if (error instanceof Error && error.message.includes('429')) { + throw new Error('Discogs API rate limit exceeded. Please try again later.') + } + throw new Error(`Failed to edit custom field: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } } // Export singleton instance diff --git a/src/mcp/tools/authenticated.ts b/src/mcp/tools/authenticated.ts index 146c7b9..5d9ee15 100644 --- a/src/mcp/tools/authenticated.ts +++ b/src/mcp/tools/authenticated.ts @@ -1298,4 +1298,598 @@ export function registerAuthenticatedTools(server: McpServer, env: Env, getSessi } }, ) + + // ────────────────────────────────────────────── + // Collection write tools + // ────────────────────────────────────────────── + + /** + * Tool: list_folders + * List all collection folders + */ + server.tool( + 'list_folders', + 'List all folders in your Discogs collection. Shows folder ID, name, and release count for each folder. Folder 0 is "All" (virtual), folder 1 is "Uncategorized" (default).', + {}, + async () => { + const { session, connectionId } = await getSessionContext() + + if (!session) { + return { + content: [ + { + type: 'text', + text: generateAuthInstructions(connectionId), + }, + ], + } + } + + try { + const userProfile = await client.getUserProfile( + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + const folders = await client.listFolders( + userProfile.username, + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + let text = `**Collection Folders for ${userProfile.username}**\n\n` + text += `Total folders: ${folders.length}\n\n` + + for (const folder of folders) { + text += `• **${folder.name}** (ID: ${folder.id}) — ${folder.count} releases\n` + } + + return { + content: [{ type: 'text', text }], + } + } catch (error) { + throw new Error(`Failed to list folders: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, + ) + + /** + * Tool: create_folder + * Create a new collection folder + */ + server.tool( + 'create_folder', + 'Create a new folder in your Discogs collection for organizing releases.', + { + name: z.string().min(1).max(100).describe('Name for the new folder'), + }, + async ({ name }) => { + const { session, connectionId } = await getSessionContext() + + if (!session) { + return { + content: [ + { + type: 'text', + text: generateAuthInstructions(connectionId), + }, + ], + } + } + + try { + const userProfile = await client.getUserProfile( + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + const folder = await client.createFolder( + userProfile.username, + name, + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + return { + content: [ + { + type: 'text', + text: `Created folder **${folder.name}** (ID: ${folder.id})`, + }, + ], + } + } catch (error) { + throw new Error(`Failed to create folder: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, + ) + + /** + * Tool: edit_folder + * Rename a collection folder + */ + server.tool( + 'edit_folder', + 'Rename an existing folder in your Discogs collection. Cannot rename the system folders (All or Uncategorized).', + { + folder_id: z.number().min(2).describe('ID of the folder to rename (must be 2 or higher — system folders cannot be renamed)'), + name: z.string().min(1).max(100).describe('New name for the folder'), + }, + async ({ folder_id, name }) => { + const { session, connectionId } = await getSessionContext() + + if (!session) { + return { + content: [ + { + type: 'text', + text: generateAuthInstructions(connectionId), + }, + ], + } + } + + try { + const userProfile = await client.getUserProfile( + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + const folder = await client.editFolder( + userProfile.username, + folder_id, + name, + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + return { + content: [ + { + type: 'text', + text: `Renamed folder ${folder_id} to **${folder.name}**`, + }, + ], + } + } catch (error) { + throw new Error(`Failed to edit folder: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, + ) + + /** + * Tool: delete_folder + * Delete a collection folder (must be empty) + */ + server.tool( + 'delete_folder', + 'Delete a folder from your Discogs collection. The folder must be empty (no releases). Cannot delete system folders (All or Uncategorized).', + { + folder_id: z.number().min(2).describe('ID of the folder to delete (must be 2 or higher — system folders cannot be deleted)'), + }, + async ({ folder_id }) => { + const { session, connectionId } = await getSessionContext() + + if (!session) { + return { + content: [ + { + type: 'text', + text: generateAuthInstructions(connectionId), + }, + ], + } + } + + try { + const userProfile = await client.getUserProfile( + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + await client.deleteFolder( + userProfile.username, + folder_id, + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + return { + content: [ + { + type: 'text', + text: `Deleted folder ${folder_id}`, + }, + ], + } + } catch (error) { + throw new Error(`Failed to delete folder: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, + ) + + /** + * Tool: add_to_collection + * Add a release to a collection folder + */ + server.tool( + 'add_to_collection', + 'Add a release to a folder in your Discogs collection. If no folder is specified, adds to the Uncategorized folder (ID 1).', + { + release_id: z.number().describe('The Discogs release ID to add'), + folder_id: z.number().optional().default(1).describe('Folder ID to add the release to (default: 1 = Uncategorized)'), + }, + async ({ release_id, folder_id }) => { + const { session, connectionId } = await getSessionContext() + + if (!session) { + return { + content: [ + { + type: 'text', + text: generateAuthInstructions(connectionId), + }, + ], + } + } + + try { + const userProfile = await client.getUserProfile( + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + const result = await client.addToFolder( + userProfile.username, + folder_id, + release_id, + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + return { + content: [ + { + type: 'text', + text: `Added release ${release_id} to folder ${folder_id} (instance ID: ${result.instance_id})`, + }, + ], + } + } catch (error) { + throw new Error(`Failed to add to collection: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, + ) + + /** + * Tool: remove_from_collection + * Remove a release instance from a collection folder + */ + server.tool( + 'remove_from_collection', + 'Remove a specific release instance from a folder in your Discogs collection. Use search_collection to find the instance_id for a release.', + { + folder_id: z.number().describe('Folder ID containing the release'), + release_id: z.number().describe('The Discogs release ID'), + instance_id: z.number().describe('The specific instance ID to remove (from search_collection results)'), + }, + async ({ folder_id, release_id, instance_id }) => { + const { session, connectionId } = await getSessionContext() + + if (!session) { + return { + content: [ + { + type: 'text', + text: generateAuthInstructions(connectionId), + }, + ], + } + } + + try { + const userProfile = await client.getUserProfile( + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + await client.removeFromFolder( + userProfile.username, + folder_id, + release_id, + instance_id, + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + return { + content: [ + { + type: 'text', + text: `Removed release ${release_id} (instance ${instance_id}) from folder ${folder_id}`, + }, + ], + } + } catch (error) { + throw new Error(`Failed to remove from collection: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, + ) + + /** + * Tool: move_release + * Move a release instance to a different folder + */ + server.tool( + 'move_release', + 'Move a release instance to a different folder in your Discogs collection. Use search_collection to find release and instance IDs, and list_folders to see available folders.', + { + folder_id: z.number().describe('Current folder ID containing the release'), + release_id: z.number().describe('The Discogs release ID'), + instance_id: z.number().describe('The specific instance ID to move'), + target_folder_id: z.number().describe('Destination folder ID'), + }, + async ({ folder_id, release_id, instance_id, target_folder_id }) => { + const { session, connectionId } = await getSessionContext() + + if (!session) { + return { + content: [ + { + type: 'text', + text: generateAuthInstructions(connectionId), + }, + ], + } + } + + try { + const userProfile = await client.getUserProfile( + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + await client.editInstance( + userProfile.username, + folder_id, + release_id, + instance_id, + { folder_id: target_folder_id }, + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + return { + content: [ + { + type: 'text', + text: `Moved release ${release_id} (instance ${instance_id}) from folder ${folder_id} to folder ${target_folder_id}`, + }, + ], + } + } catch (error) { + throw new Error(`Failed to move release: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, + ) + + /** + * Tool: rate_release + * Rate a release in your collection (0-5 stars) + */ + server.tool( + 'rate_release', + 'Rate a release in your Discogs collection from 0 (no rating) to 5 stars. Use search_collection to find release and instance IDs.', + { + folder_id: z.number().describe('Folder ID containing the release'), + release_id: z.number().describe('The Discogs release ID'), + instance_id: z.number().describe('The specific instance ID to rate'), + rating: z.number().min(0).max(5).describe('Rating from 0 (remove rating) to 5 stars'), + }, + async ({ folder_id, release_id, instance_id, rating }) => { + const { session, connectionId } = await getSessionContext() + + if (!session) { + return { + content: [ + { + type: 'text', + text: generateAuthInstructions(connectionId), + }, + ], + } + } + + try { + const userProfile = await client.getUserProfile( + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + await client.editInstance( + userProfile.username, + folder_id, + release_id, + instance_id, + { rating }, + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + const ratingText = rating === 0 ? 'Removed rating from' : `Rated ${rating}/5 stars:` + return { + content: [ + { + type: 'text', + text: `${ratingText} release ${release_id} (instance ${instance_id})`, + }, + ], + } + } catch (error) { + throw new Error(`Failed to rate release: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, + ) + + /** + * Tool: list_custom_fields + * List custom fields defined in the user's collection + */ + server.tool( + 'list_custom_fields', + 'List all custom fields defined in your Discogs collection. Custom fields allow you to add metadata like notes, tags, or categories to releases.', + {}, + async () => { + const { session, connectionId } = await getSessionContext() + + if (!session) { + return { + content: [ + { + type: 'text', + text: generateAuthInstructions(connectionId), + }, + ], + } + } + + try { + const userProfile = await client.getUserProfile( + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + const fields = await client.listCustomFields( + userProfile.username, + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + if (fields.length === 0) { + return { + content: [ + { + type: 'text', + text: `**Custom Fields for ${userProfile.username}**\n\nNo custom fields defined. You can create custom fields in your Discogs collection settings at discogs.com.`, + }, + ], + } + } + + let text = `**Custom Fields for ${userProfile.username}**\n\n` + for (const field of fields) { + text += `• **${field.name}** (ID: ${field.id}, type: ${field.type})` + if (field.options && field.options.length > 0) { + text += `\n Options: ${field.options.join(', ')}` + } + text += '\n' + } + + return { + content: [{ type: 'text', text }], + } + } catch (error) { + throw new Error(`Failed to list custom fields: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, + ) + + /** + * Tool: edit_custom_field + * Set a custom field value on a collection instance + */ + server.tool( + 'edit_custom_field', + 'Set a custom field value on a release in your Discogs collection. Use list_custom_fields to see available fields and their IDs. For dropdown fields, the value must match one of the defined options.', + { + folder_id: z.number().describe('Folder ID containing the release'), + release_id: z.number().describe('The Discogs release ID'), + instance_id: z.number().describe('The specific instance ID'), + field_id: z.number().describe('Custom field ID (from list_custom_fields)'), + value: z.string().describe('Value to set for the field'), + }, + async ({ folder_id, release_id, instance_id, field_id, value }) => { + const { session, connectionId } = await getSessionContext() + + if (!session) { + return { + content: [ + { + type: 'text', + text: generateAuthInstructions(connectionId), + }, + ], + } + } + + try { + const userProfile = await client.getUserProfile( + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + await client.editCustomFieldValue( + userProfile.username, + folder_id, + release_id, + instance_id, + field_id, + value, + session.accessToken, + session.accessTokenSecret, + env.DISCOGS_CONSUMER_KEY, + env.DISCOGS_CONSUMER_SECRET, + ) + + return { + content: [ + { + type: 'text', + text: `Updated field ${field_id} to "${value}" on release ${release_id} (instance ${instance_id})`, + }, + ], + } + } catch (error) { + throw new Error(`Failed to edit custom field: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, + ) } diff --git a/test/clients/cachedDiscogs-write.test.ts b/test/clients/cachedDiscogs-write.test.ts new file mode 100644 index 0000000..a81a424 --- /dev/null +++ b/test/clients/cachedDiscogs-write.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { CachedDiscogsClient } from '../../src/clients/cachedDiscogs' +import { DiscogsClient } from '../../src/clients/discogs' + +// Minimal KV mock +function makeKV(): KVNamespace { + const store = new Map() + return { + get: vi.fn(async (key: string) => store.get(key) ?? null), + put: vi.fn(async (key: string, value: string) => { + store.set(key, value) + }), + delete: vi.fn(async (key: string) => { + store.delete(key) + }), + list: vi.fn(async () => ({ keys: [], list_complete: true, cursor: '' })), + } as unknown as KVNamespace +} + +// Stub DiscogsClient with mock methods for all write operations +function makeMockClient() { + return { + setKV: vi.fn(), + listFolders: vi.fn(async () => [{ id: 0, name: 'All', count: 10, resource_url: '' }]), + createFolder: vi.fn(async () => ({ id: 3, name: 'New', count: 0, resource_url: '' })), + editFolder: vi.fn(async () => ({ id: 3, name: 'Renamed', count: 0, resource_url: '' })), + deleteFolder: vi.fn(async () => undefined), + addToFolder: vi.fn(async () => ({ instance_id: 42, resource_url: '' })), + removeFromFolder: vi.fn(async () => undefined), + editInstance: vi.fn(async () => undefined), + listCustomFields: vi.fn(async () => [{ id: 1, name: 'Notes', type: 'textarea', public: true, position: 1 }]), + editCustomFieldValue: vi.fn(async () => undefined), + } as unknown as DiscogsClient +} + +describe('CachedDiscogsClient — write operations & cache invalidation', () => { + let cached: CachedDiscogsClient + let mockClient: DiscogsClient + let invalidateSpy: ReturnType + + const a = ['token', 'secret', 'key', 'csecret'] as const + + beforeEach(() => { + vi.clearAllMocks() + const kv = makeKV() + mockClient = makeMockClient() + cached = new CachedDiscogsClient(mockClient, kv) + invalidateSpy = vi.spyOn(cached, 'invalidateUserCache' as never).mockResolvedValue(undefined as never) + }) + + describe('read-only operations (no cache invalidation)', () => { + it('listFolders passes through without invalidating cache', async () => { + await cached.listFolders('user', ...a) + + expect((mockClient as any).listFolders).toHaveBeenCalledOnce() + expect(invalidateSpy).not.toHaveBeenCalled() + }) + + it('listCustomFields passes through without invalidating cache', async () => { + await cached.listCustomFields('user', ...a) + + expect((mockClient as any).listCustomFields).toHaveBeenCalledOnce() + expect(invalidateSpy).not.toHaveBeenCalled() + }) + }) + + describe('write operations (invalidate user cache)', () => { + it('createFolder invalidates user cache', async () => { + const result = await cached.createFolder('user', 'New', ...a) + + expect(result.name).toBe('New') + expect(invalidateSpy).toHaveBeenCalledWith('user') + }) + + it('editFolder invalidates user cache', async () => { + await cached.editFolder('user', 3, 'Renamed', ...a) + + expect(invalidateSpy).toHaveBeenCalledWith('user') + }) + + it('deleteFolder invalidates user cache', async () => { + await cached.deleteFolder('user', 3, ...a) + + expect(invalidateSpy).toHaveBeenCalledWith('user') + }) + + it('addToFolder invalidates user cache', async () => { + const result = await cached.addToFolder('user', 1, 12345, ...a) + + expect(result.instance_id).toBe(42) + expect(invalidateSpy).toHaveBeenCalledWith('user') + }) + + it('removeFromFolder invalidates user cache', async () => { + await cached.removeFromFolder('user', 1, 12345, 99, ...a) + + expect(invalidateSpy).toHaveBeenCalledWith('user') + }) + + it('editInstance invalidates user cache', async () => { + await cached.editInstance('user', 1, 12345, 99, { rating: 5 }, ...a) + + expect(invalidateSpy).toHaveBeenCalledWith('user') + }) + }) + + describe('editCustomFieldValue (no cache invalidation)', () => { + it('does NOT invalidate cache — custom fields do not affect collection structure', async () => { + await cached.editCustomFieldValue('user', 1, 12345, 99, 2, 'Near Mint', ...a) + + expect((mockClient as any).editCustomFieldValue).toHaveBeenCalledOnce() + expect(invalidateSpy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/test/clients/discogs-write.test.ts b/test/clients/discogs-write.test.ts new file mode 100644 index 0000000..9cdb443 --- /dev/null +++ b/test/clients/discogs-write.test.ts @@ -0,0 +1,363 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { discogsClient } from '../../src/clients/discogs' + +// Mock fetch globally +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +const auth = { + username: 'testuser', + accessToken: 'test-token', + accessTokenSecret: 'test-secret', + consumerKey: 'test-key', + consumerSecret: 'test-secret-key', +} + +function mockOk(body: unknown) { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(body), + }) +} + +function mock204() { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + json: () => Promise.resolve({}), + }) +} + +describe('Discogs Client — Collection Write Operations', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listFolders', () => { + it('should return folders array', async () => { + const folders = [ + { id: 0, name: 'All', count: 100, resource_url: 'https://api.discogs.com/users/testuser/collection/folders/0' }, + { id: 1, name: 'Uncategorized', count: 80, resource_url: 'https://api.discogs.com/users/testuser/collection/folders/1' }, + { id: 2, name: 'Favorites', count: 20, resource_url: 'https://api.discogs.com/users/testuser/collection/folders/2' }, + ] + mockOk({ folders }) + + const result = await discogsClient.listFolders( + auth.username, + auth.accessToken, + auth.accessTokenSecret, + auth.consumerKey, + auth.consumerSecret, + ) + + expect(result).toHaveLength(3) + expect(result[0].name).toBe('All') + expect(result[2].name).toBe('Favorites') + }) + + it('should wrap errors with descriptive message', async () => { + // Use a non-retriable error to avoid retry delays + mockFetch.mockRejectedValueOnce(new Error('Forbidden')) + + await expect( + discogsClient.listFolders(auth.username, auth.accessToken, auth.accessTokenSecret, auth.consumerKey, auth.consumerSecret), + ).rejects.toThrow('Failed to list folders') + }) + }) + + describe('createFolder', () => { + it('should create folder and return it', async () => { + const folder = { id: 3, name: 'New Arrivals', count: 0, resource_url: 'https://api.discogs.com/users/testuser/collection/folders/3' } + mockOk(folder) + + const result = await discogsClient.createFolder( + auth.username, + 'New Arrivals', + auth.accessToken, + auth.accessTokenSecret, + auth.consumerKey, + auth.consumerSecret, + ) + + expect(result.id).toBe(3) + expect(result.name).toBe('New Arrivals') + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/users/testuser/collection/folders'), + expect.objectContaining({ method: 'POST' }), + ) + }) + + it('should send name in request body', async () => { + mockOk({ id: 3, name: 'Test', count: 0 }) + + await discogsClient.createFolder( + auth.username, + 'Test', + auth.accessToken, + auth.accessTokenSecret, + auth.consumerKey, + auth.consumerSecret, + ) + + const callArgs = mockFetch.mock.calls[0] + expect(JSON.parse(callArgs[1].body)).toEqual({ name: 'Test' }) + }) + }) + + describe('editFolder', () => { + it('should rename folder and return updated folder', async () => { + const folder = { id: 2, name: 'Renamed', count: 10, resource_url: '' } + mockOk(folder) + + const result = await discogsClient.editFolder( + auth.username, + 2, + 'Renamed', + auth.accessToken, + auth.accessTokenSecret, + auth.consumerKey, + auth.consumerSecret, + ) + + expect(result.name).toBe('Renamed') + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/collection/folders/2'), expect.objectContaining({ method: 'POST' })) + }) + }) + + describe('deleteFolder', () => { + it('should send DELETE request', async () => { + mock204() + + await discogsClient.deleteFolder(auth.username, 2, auth.accessToken, auth.accessTokenSecret, auth.consumerKey, auth.consumerSecret) + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/collection/folders/2'), + expect.objectContaining({ method: 'DELETE' }), + ) + }) + }) + + describe('addToFolder', () => { + it('should return instance_id on success', async () => { + mockOk({ instance_id: 42, resource_url: 'https://api.discogs.com/...' }) + + const result = await discogsClient.addToFolder( + auth.username, + 1, + 12345, + auth.accessToken, + auth.accessTokenSecret, + auth.consumerKey, + auth.consumerSecret, + ) + + expect(result.instance_id).toBe(42) + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/folders/1/releases/12345'), + expect.objectContaining({ method: 'POST' }), + ) + }) + }) + + describe('removeFromFolder', () => { + it('should send DELETE with correct URL path', async () => { + mock204() + + await discogsClient.removeFromFolder( + auth.username, + 1, + 12345, + 99, + auth.accessToken, + auth.accessTokenSecret, + auth.consumerKey, + auth.consumerSecret, + ) + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/folders/1/releases/12345/instances/99'), + expect.objectContaining({ method: 'DELETE' }), + ) + }) + }) + + describe('editInstance', () => { + it('should send rating change', async () => { + mock204() + + await discogsClient.editInstance( + auth.username, + 1, + 12345, + 99, + { rating: 5 }, + auth.accessToken, + auth.accessTokenSecret, + auth.consumerKey, + auth.consumerSecret, + ) + + const callArgs = mockFetch.mock.calls[0] + expect(JSON.parse(callArgs[1].body)).toEqual({ rating: 5 }) + expect(callArgs[1].method).toBe('POST') + }) + + it('should send folder move', async () => { + mock204() + + await discogsClient.editInstance( + auth.username, + 1, + 12345, + 99, + { folder_id: 3 }, + auth.accessToken, + auth.accessTokenSecret, + auth.consumerKey, + auth.consumerSecret, + ) + + const callArgs = mockFetch.mock.calls[0] + expect(JSON.parse(callArgs[1].body)).toEqual({ folder_id: 3 }) + }) + }) + + describe('listCustomFields', () => { + it('should return fields array', async () => { + const fields = [ + { id: 1, name: 'Notes', type: 'textarea', public: true, position: 1, lines: 3 }, + { id: 2, name: 'Condition', type: 'dropdown', public: false, position: 2, options: ['Mint', 'Near Mint', 'Good'] }, + ] + mockOk({ fields }) + + const result = await discogsClient.listCustomFields( + auth.username, + auth.accessToken, + auth.accessTokenSecret, + auth.consumerKey, + auth.consumerSecret, + ) + + expect(result).toHaveLength(2) + expect(result[0].name).toBe('Notes') + expect(result[1].options).toEqual(['Mint', 'Near Mint', 'Good']) + }) + }) + + describe('editCustomFieldValue', () => { + it('should send value in request body', async () => { + mock204() + + await discogsClient.editCustomFieldValue( + auth.username, + 1, + 12345, + 99, + 2, + 'Near Mint', + auth.accessToken, + auth.accessTokenSecret, + auth.consumerKey, + auth.consumerSecret, + ) + + const callArgs = mockFetch.mock.calls[0] + expect(callArgs[0]).toContain('/instances/99/fields/2') + expect(JSON.parse(callArgs[1].body)).toEqual({ value: 'Near Mint' }) + expect(callArgs[1].method).toBe('POST') + }) + }) +}) + +describe('Discogs Client — 429 rate limit handling', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.resetAllMocks() + mockFetch.mockRejectedValue(new Error('429 Too Many Requests')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + async function expectRateLimitError(promise: Promise) { + const result = promise.catch((e: Error) => e) + await vi.runAllTimersAsync() + const error = await result + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain('rate limit') + } + + it('listFolders throws rate limit error', async () => { + await expectRateLimitError( + discogsClient.listFolders(auth.username, auth.accessToken, auth.accessTokenSecret, auth.consumerKey, auth.consumerSecret), + ) + }) + + it('createFolder throws rate limit error', async () => { + await expectRateLimitError( + discogsClient.createFolder(auth.username, 'Test', auth.accessToken, auth.accessTokenSecret, auth.consumerKey, auth.consumerSecret), + ) + }) + + it('deleteFolder throws rate limit error', async () => { + await expectRateLimitError( + discogsClient.deleteFolder(auth.username, 2, auth.accessToken, auth.accessTokenSecret, auth.consumerKey, auth.consumerSecret), + ) + }) + + it('addToFolder throws rate limit error', async () => { + await expectRateLimitError( + discogsClient.addToFolder(auth.username, 1, 12345, auth.accessToken, auth.accessTokenSecret, auth.consumerKey, auth.consumerSecret), + ) + }) + + it('removeFromFolder throws rate limit error', async () => { + await expectRateLimitError( + discogsClient.removeFromFolder( + auth.username, + 1, + 12345, + 99, + auth.accessToken, + auth.accessTokenSecret, + auth.consumerKey, + auth.consumerSecret, + ), + ) + }) + + it('editInstance throws rate limit error', async () => { + await expectRateLimitError( + discogsClient.editInstance( + auth.username, + 1, + 12345, + 99, + { rating: 3 }, + auth.accessToken, + auth.accessTokenSecret, + auth.consumerKey, + auth.consumerSecret, + ), + ) + }) + + it('editCustomFieldValue throws rate limit error', async () => { + await expectRateLimitError( + discogsClient.editCustomFieldValue( + auth.username, + 1, + 12345, + 99, + 2, + 'test', + auth.accessToken, + auth.accessTokenSecret, + auth.consumerKey, + auth.consumerSecret, + ), + ) + }) +}) diff --git a/test/integration/mcp-client.test.ts b/test/integration/mcp-client.test.ts index 1ea8ff5..c41beb4 100644 --- a/test/integration/mcp-client.test.ts +++ b/test/integration/mcp-client.test.ts @@ -129,14 +129,12 @@ class MockMCPClient { } private async makeRequest(body: any): Promise { - const url = this.sessionId - ? `http://localhost:8787/mcp?session_id=${this.sessionId}` - : 'http://localhost:8787/mcp' + const url = this.sessionId ? `http://localhost:8787/mcp?session_id=${this.sessionId}` : 'http://localhost:8787/mcp' const request = new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Accept': 'application/json, text/event-stream', + Accept: 'application/json, text/event-stream', }, body: JSON.stringify(body), }) @@ -368,7 +366,7 @@ describe('MCP Client Integration Tests', () => { // Test tools const toolsList = await client.listTools() - expect(toolsList.result.tools).toHaveLength(8) + expect(toolsList.result.tools).toHaveLength(18) const searchResult = await client.callTool('search_collection', { query: 'Beatles' }) expect(searchResult.result).toBeDefined()