v1.0
This commit is contained in:
1 parent
0246cbb6ba
commit
0ca4b4bf7a
89 files changed
+7042
-2480
No files matched your search
-10
@@ -1,10 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
groups:
|
||||
dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
lint-typecheck:
|
||||
name: Lint and Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run Biome linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Run TypeScript type checking
|
||||
run: npm run typecheck
|
||||
@@ -372,6 +372,14 @@ npm run auth
|
||||
**Note**: The `expiresAt` field is a Unix timestamp (in milliseconds) indicating when the access token expires.
|
||||
|
||||
7. **Automatic Token Refresh**: The server will automatically refresh the access token when it expires (typically after 1 hour). The refresh happens transparently using the `refreshToken`, so you don't need to re-authenticate manually. If the refresh fails, you'll need to run `npm run auth` again to re-authenticate.
|
||||
- Tokens are refreshed one minute before they expire, at most once even when several tool calls arrive together, and a rotated `refresh_token` returned by Spotify is saved.
|
||||
- Spotify refresh tokens expire after **6 months**; the server then reports "refresh token expired or revoked — run `npm run auth`".
|
||||
- `spotify-config.json` is rewritten atomically with owner-only permissions (0600 on Linux/macOS).
|
||||
|
||||
**Errors:** failed tool calls are returned with `isError: true` (MCP tool error), so
|
||||
clients don't treat "Error …" texts as successful results. Every tool carries MCP
|
||||
annotations (`readOnlyHint` for lookups, `destructiveHint` for removals and
|
||||
playlist edits).
|
||||
|
||||
## Integrating with Claude Desktop, Cursor, and VsCode [Via Cline model extension](https://marketplace.visualstudio.com/items/?itemName=saoudrizwan.claude-dev)
|
||||
|
||||
|
||||
@@ -1,20 +1,98 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import type {
|
||||
CallToolResult,
|
||||
ToolAnnotations,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { albumTools } from './albums.js';
|
||||
import { playTools } from './play.js';
|
||||
import { playlistTools } from './playlist.js';
|
||||
import { readTools } from './read.js';
|
||||
import type { SpotifyHandlerExtra, tool } from './types.js';
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'spotify-controller',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
[...readTools, ...playTools, ...albumTools, ...playlistTools].forEach(
|
||||
(tool) => {
|
||||
server.tool(tool.name, tool.description, tool.schema, tool.handler);
|
||||
},
|
||||
);
|
||||
const READ: ToolAnnotations = { readOnlyHint: true, openWorldHint: true };
|
||||
const CONTROL: ToolAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
openWorldHint: true,
|
||||
};
|
||||
const REMOVE: ToolAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
openWorldHint: true,
|
||||
};
|
||||
|
||||
const ANNOTATIONS: Record<string, ToolAnnotations> = {
|
||||
searchSpotify: READ,
|
||||
getNowPlaying: READ,
|
||||
getMyPlaylists: READ,
|
||||
getPlaylistTracks: READ,
|
||||
getRecentlyPlayed: READ,
|
||||
getUsersSavedTracks: READ,
|
||||
getQueue: READ,
|
||||
getAvailableDevices: READ,
|
||||
getPlaylist: READ,
|
||||
getAlbums: READ,
|
||||
getAlbumTracks: READ,
|
||||
checkUsersSavedAlbums: READ,
|
||||
removeUsersSavedTracks: REMOVE,
|
||||
removeTracksFromPlaylist: REMOVE,
|
||||
reorderPlaylistItems: REMOVE,
|
||||
saveOrRemoveAlbumForUser: REMOVE,
|
||||
updatePlaylist: REMOVE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Handlers report failures as text starting with "Error"; flag those (and
|
||||
* thrown errors) with isError so clients don't treat them as success.
|
||||
*/
|
||||
async function runTool(
|
||||
t: tool<any>,
|
||||
args: unknown,
|
||||
extra: SpotifyHandlerExtra,
|
||||
): Promise<CallToolResult> {
|
||||
try {
|
||||
const result = await t.handler(args as never, extra);
|
||||
const first = result.content[0]?.text ?? '';
|
||||
return /^Error\b/.test(first) ? { ...result, isError: true } : result;
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error in ${t.name}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const allTools: tool<any>[] = [
|
||||
...readTools,
|
||||
...playTools,
|
||||
...albumTools,
|
||||
...playlistTools,
|
||||
];
|
||||
for (const t of allTools) {
|
||||
server.registerTool(
|
||||
t.name,
|
||||
{
|
||||
description: t.description,
|
||||
inputSchema: t.schema,
|
||||
annotations: ANNOTATIONS[t.name] ?? CONTROL,
|
||||
},
|
||||
((args: unknown, extra: SpotifyHandlerExtra) =>
|
||||
runTool(t, args, extra)) as any,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import type { SpotifyHandlerExtra, tool } from './types.js';
|
||||
import { handleSpotifyRequest, spotifyFetch } from './utils.js';
|
||||
import { handleSpotifyRequest, seg, spotifyFetch } from './utils.js';
|
||||
|
||||
const playMusic: tool<{
|
||||
uri: z.ZodOptional<z.ZodString>;
|
||||
@@ -43,6 +43,9 @@ const playMusic: tool<{
|
||||
if (!spotifyUri && type && id) {
|
||||
spotifyUri = `spotify:${type}:${id}`;
|
||||
}
|
||||
// A bare `uri` has no `type`: tracks/episodes must go in `uris`, while
|
||||
// albums/artists/playlists are a `context_uri` (Spotify rejects the mix-up).
|
||||
const kind = type ?? spotifyUri?.split(':')[1];
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
@@ -53,7 +56,7 @@ const playMusic: tool<{
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'track') {
|
||||
if (kind === 'track' || kind === 'episode') {
|
||||
await spotifyApi.player.startResumePlayback(device, undefined, [
|
||||
spotifyUri,
|
||||
]);
|
||||
@@ -66,7 +69,7 @@ const playMusic: tool<{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Started playing ${type || 'music'} ${id ? `(ID: ${id})` : ''}`,
|
||||
text: `Started playing ${kind || 'music'} ${id ? `(ID: ${id})` : spotifyUri ? `(${spotifyUri})` : ''}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -226,7 +229,7 @@ const createPlaylist: tool<{
|
||||
const playlist = await spotifyFetch<{
|
||||
id: string;
|
||||
external_urls: { spotify: string };
|
||||
}>(`users/${me.id}/playlists`, {
|
||||
}>(`users/${seg(me.id)}/playlists`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name,
|
||||
@@ -292,7 +295,7 @@ const addTracksToPlaylist: tool<{
|
||||
const trackUris = trackIds.map((id) => `spotify:track:${id}`);
|
||||
|
||||
// Hit /items directly: see spotifyFetch JSDoc for context.
|
||||
await spotifyFetch(`playlists/${playlistId}/items`, {
|
||||
await spotifyFetch(`playlists/${seg(playlistId)}/items`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
uris: trackUris,
|
||||
@@ -511,29 +514,37 @@ const adjustVolume: tool<{
|
||||
const { adjustment, deviceId } = args;
|
||||
|
||||
try {
|
||||
// First get the current playback state to find current volume
|
||||
// Read the volume of the device we are about to change, not just the
|
||||
// currently active one.
|
||||
const playback = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.player.getPlaybackState();
|
||||
});
|
||||
let device: typeof playback.device | undefined = playback?.device;
|
||||
if (deviceId && device?.id !== deviceId) {
|
||||
const available = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.player.getAvailableDevices();
|
||||
});
|
||||
device = available?.devices?.find((d) => d.id === deviceId);
|
||||
}
|
||||
|
||||
if (!playback?.device) {
|
||||
if (!device) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'No active device found. Make sure Spotify is open and playing on a device.',
|
||||
text: 'Error: No active device found. Make sure Spotify is open and playing on a device.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const currentVolume = playback.device.volume_percent;
|
||||
const currentVolume = device.volume_percent;
|
||||
if (currentVolume === null || currentVolume === undefined) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Unable to get current volume from device.',
|
||||
text: 'Error: Unable to get current volume from device.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import type { SpotifyHandlerExtra, tool } from './types.js';
|
||||
import { handleSpotifyRequest, spotifyFetch } from './utils.js';
|
||||
import { handleSpotifyRequest, seg, spotifyFetch } from './utils.js';
|
||||
|
||||
const getPlaylist: tool<{
|
||||
playlistId: z.ZodString;
|
||||
@@ -177,7 +177,7 @@ const removeTracksFromPlaylist: tool<{
|
||||
|
||||
// Hit /items directly: SDK targets the deprecated /tracks endpoint
|
||||
// (see spotifyFetch JSDoc for context on the March 2026 migration).
|
||||
await spotifyFetch(`playlists/${playlistId}/items`, {
|
||||
await spotifyFetch(`playlists/${seg(playlistId)}/items`, {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
items,
|
||||
@@ -250,7 +250,7 @@ const reorderPlaylistItems: tool<{
|
||||
|
||||
try {
|
||||
// Hit /items directly: see spotifyFetch JSDoc for context.
|
||||
await spotifyFetch(`playlists/${playlistId}/items`, {
|
||||
await spotifyFetch(`playlists/${seg(playlistId)}/items`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
range_start: rangeStart,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SpotifyHandlerExtra, SpotifyTrack, tool } from './types.js';
|
||||
import {
|
||||
formatDuration,
|
||||
handleSpotifyRequest,
|
||||
seg,
|
||||
spotifyFetch,
|
||||
} from './utils.js';
|
||||
|
||||
@@ -80,9 +81,11 @@ const searchSpotify: tool<{
|
||||
} else if (type === 'playlist' && results.playlists) {
|
||||
formattedResults = results.playlists.items
|
||||
.map((playlist, i) => {
|
||||
return `${i + 1}. "${playlist?.name ?? 'Unknown Playlist'} (${
|
||||
playlist?.description ?? 'No description'
|
||||
} tracks)" by ${playlist?.owner?.display_name} - ID: ${
|
||||
// Search results carry `tracks` (or `items`) totals at runtime.
|
||||
const total = (playlist as any)?.tracks?.total ?? (playlist as any)?.items?.total;
|
||||
return `${i + 1}. "${playlist?.name ?? 'Unknown Playlist'}"${
|
||||
total !== undefined ? ` (${total} tracks)` : ''
|
||||
} by ${playlist?.owner?.display_name ?? 'Unknown'} - ID: ${
|
||||
playlist?.id
|
||||
}`;
|
||||
})
|
||||
@@ -298,7 +301,7 @@ const getPlaylistTracks: tool<{
|
||||
const playlistTracks = await spotifyFetch<{
|
||||
items: PlaylistItemEntry[];
|
||||
total: number;
|
||||
}>(`playlists/${playlistId}/items`, {
|
||||
}>(`playlists/${seg(playlistId)}/items`, {
|
||||
query: { limit, offset },
|
||||
});
|
||||
|
||||
@@ -459,7 +462,7 @@ const getUsersSavedTracks: tool<{
|
||||
const formattedTracks = savedTracks.items
|
||||
.map((item, i) => {
|
||||
const track = item.track;
|
||||
if (!track) return `${i + 1}. [Removed track]`;
|
||||
if (!track) return `${offset + i + 1}. [Removed track]`;
|
||||
|
||||
if (isTrack(track)) {
|
||||
const artists = track.artists.map((a) => a.name).join(', ');
|
||||
@@ -468,7 +471,7 @@ const getUsersSavedTracks: tool<{
|
||||
return `${offset + i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id} - Added: ${addedDate}`;
|
||||
}
|
||||
|
||||
return `${i + 1}. Unknown item`;
|
||||
return `${offset + i + 1}. Unknown item`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export type tool<Args extends z.ZodRawShape> = {
|
||||
name: string;
|
||||
description: string;
|
||||
schema: Args;
|
||||
// Failure messages start with "Error": index.ts turns them into isError results.
|
||||
handler: (
|
||||
args: z.infer<z.ZodObject<Args>>,
|
||||
extra: SpotifyHandlerExtra,
|
||||
@@ -23,12 +24,14 @@ export type tool<Args extends z.ZodRawShape> = {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
isError?: boolean;
|
||||
}>
|
||||
| {
|
||||
content: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
isError?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -43,11 +43,57 @@ export function loadSpotifyConfig(): SpotifyConfig {
|
||||
}
|
||||
|
||||
export function saveSpotifyConfig(config: SpotifyConfig): void {
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
||||
// Holds the client secret and refresh token: owner-only, written atomically.
|
||||
const tmp = `${CONFIG_FILE}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(config, null, 2), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.renameSync(tmp, CONFIG_FILE);
|
||||
try {
|
||||
fs.chmodSync(CONFIG_FILE, 0o600);
|
||||
} catch {
|
||||
// Not supported on every filesystem (e.g. FAT); best effort.
|
||||
}
|
||||
}
|
||||
|
||||
/** One URL path segment (IDs come from the LLM). */
|
||||
export function seg(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
let cachedSpotifyApi: SpotifyApi | null = null;
|
||||
|
||||
// Refresh slightly early so a token can't expire mid-request.
|
||||
const EXPIRY_SKEW_MS = 60_000;
|
||||
let refreshInFlight: Promise<SpotifyConfig> | null = null;
|
||||
|
||||
/**
|
||||
* Return *config* with a valid access token, refreshing it at most once even
|
||||
* when several tool calls arrive together.
|
||||
*/
|
||||
async function ensureFreshToken(config: SpotifyConfig): Promise<SpotifyConfig> {
|
||||
if (!(config.accessToken && config.refreshToken)) return config;
|
||||
if (config.expiresAt && config.expiresAt - EXPIRY_SKEW_MS > Date.now()) {
|
||||
return config;
|
||||
}
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = (async () => {
|
||||
const tokens = await refreshAccessToken(config);
|
||||
config.accessToken = tokens.access_token;
|
||||
// Spotify may rotate the refresh token; the old one can stop working.
|
||||
if (tokens.refresh_token) config.refreshToken = tokens.refresh_token;
|
||||
config.expiresAt = Date.now() + tokens.expires_in * 1000;
|
||||
saveSpotifyConfig(config);
|
||||
cachedSpotifyApi = null;
|
||||
return config;
|
||||
})().finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
return refreshInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct Spotify Web API fetch helper.
|
||||
* Used to bypass @spotify/web-api-ts-sdk methods that hit deprecated endpoints
|
||||
@@ -65,19 +111,7 @@ export async function spotifyFetch<T = unknown>(
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const { method = 'GET', body, query } = options;
|
||||
const config = loadSpotifyConfig();
|
||||
|
||||
// Refresh token if expired
|
||||
if (config.accessToken && config.refreshToken) {
|
||||
const now = Date.now();
|
||||
if (!config.expiresAt || config.expiresAt <= now) {
|
||||
const tokens = await refreshAccessToken(config);
|
||||
config.accessToken = tokens.access_token;
|
||||
config.expiresAt = now + tokens.expires_in * 1000;
|
||||
saveSpotifyConfig(config);
|
||||
cachedSpotifyApi = null;
|
||||
}
|
||||
}
|
||||
const config = await ensureFreshToken(loadSpotifyConfig());
|
||||
|
||||
if (!config.accessToken) {
|
||||
throw new Error(
|
||||
@@ -120,31 +154,19 @@ export async function spotifyFetch<T = unknown>(
|
||||
}
|
||||
|
||||
export async function createSpotifyApi(): Promise<SpotifyApi> {
|
||||
const config = loadSpotifyConfig();
|
||||
let config = loadSpotifyConfig();
|
||||
|
||||
if (config.accessToken && config.refreshToken) {
|
||||
const now = Date.now();
|
||||
const shouldRefresh = !config.expiresAt || config.expiresAt <= now;
|
||||
|
||||
if (shouldRefresh) {
|
||||
console.error(
|
||||
'Access token expired or missing expiration time, refreshing...',
|
||||
);
|
||||
try {
|
||||
const tokens = await refreshAccessToken(config);
|
||||
config.accessToken = tokens.access_token;
|
||||
config.expiresAt = now + tokens.expires_in * 1000; // Convert seconds to milliseconds
|
||||
saveSpotifyConfig(config);
|
||||
console.error('Access token refreshed successfully');
|
||||
|
||||
// Clear cached API instance to force recreation with new token
|
||||
cachedSpotifyApi = null;
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh token:', error);
|
||||
throw new Error(
|
||||
'Failed to refresh access token. Please run "npm run auth" to re-authenticate.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
config = await ensureFreshToken(config);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh token:', error);
|
||||
throw error instanceof Error && error.message.includes('npm run auth')
|
||||
? error
|
||||
: new Error(
|
||||
'Failed to refresh access token. Please run "npm run auth" to re-authenticate.',
|
||||
);
|
||||
}
|
||||
|
||||
if (cachedSpotifyApi) {
|
||||
@@ -152,12 +174,12 @@ export async function createSpotifyApi(): Promise<SpotifyApi> {
|
||||
}
|
||||
|
||||
const accessToken = {
|
||||
access_token: config.accessToken,
|
||||
access_token: config.accessToken as string,
|
||||
token_type: 'Bearer',
|
||||
expires_in: Math.floor(
|
||||
((config.expiresAt ?? now + 3600000) - now) / 1000,
|
||||
),
|
||||
refresh_token: config.refreshToken,
|
||||
refresh_token: config.refreshToken as string,
|
||||
};
|
||||
|
||||
cachedSpotifyApi = SpotifyApi.withAccessToken(config.clientId, accessToken);
|
||||
@@ -229,7 +251,7 @@ async function exchangeCodeForToken(
|
||||
|
||||
async function refreshAccessToken(
|
||||
config: SpotifyConfig,
|
||||
): Promise<{ access_token: string; expires_in: number }> {
|
||||
): Promise<{ access_token: string; expires_in: number; refresh_token?: string }> {
|
||||
if (!config.refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
@@ -252,6 +274,12 @@ async function refreshAccessToken(
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
if (errorData.includes('invalid_grant')) {
|
||||
// Refresh tokens expire after 6 months or when access is revoked.
|
||||
throw new Error(
|
||||
'Spotify refresh token expired or revoked. Run "npm run auth" to sign in again.',
|
||||
);
|
||||
}
|
||||
throw new Error(`Failed to refresh access token: ${errorData}`);
|
||||
}
|
||||
|
||||
@@ -259,6 +287,7 @@ async function refreshAccessToken(
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
expires_in: data.expires_in || 3600,
|
||||
refresh_token: data.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -412,9 +441,10 @@ export async function authorizeSpotify(): Promise<void> {
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
||||
return `${minutes}:${seconds.padStart(2, '0')}`;
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export async function handleSpotifyRequest<T>(
|
||||
|
||||
@@ -53,7 +53,7 @@ server.stdout.on('data', (data) => {
|
||||
if (pendingRequests.has(id)) {
|
||||
const req = pendingRequests.get(id);
|
||||
|
||||
if (response.error) {
|
||||
if (response.error || response.result?.isError) {
|
||||
console.log(`❌ ${req.tool}: ERROR`);
|
||||
results.push({ name: req.tool, success: false });
|
||||
req.resolve();
|
||||
|
||||
Reference in new issue
Block a user