From a7886b5b2b3a7987de6a58700b06c020ab030470 Mon Sep 17 00:00:00 2001 From: Seton Carmichael Date: Sun, 21 Jun 2026 19:39:31 -0400 Subject: [PATCH] Initial commit: Outlook MCP Server v1.0.0 MCP server providing Microsoft Outlook integration via Graph API: - Email: list, search, read, send, thread reconstruction - Calendar: list, create, decline, cancel, delete events - Folders: list, create, move emails - Inbox rules: list, create, reorder - MSAL device code flow auth with persistent token cache - Test mode with mock data - Comprehensive README with setup, config, and tool reference 20 MCP tools across 5 modules. Node.js >= 14. MIT license. --- .env.example | 13 + .gitignore | 33 + README.md | 304 ++++ auth/index.js | 31 + auth/token-manager.js | 166 ++ auth/tools.js | 113 ++ calendar/accept.js | 64 + calendar/cancel.js | 64 + calendar/create.js | 68 + calendar/decline.js | 64 + calendar/delete.js | 59 + calendar/index.js | 131 ++ calendar/list.js | 94 ++ config.js | 49 + email/folder-utils.js | 171 ++ email/index.js | 168 ++ email/list.js | 100 ++ email/read-multiple.js | 155 ++ email/read.js | 126 ++ email/search.js | 257 +++ email/send.js | 120 ++ folder/create.js | 124 ++ folder/index.js | 78 + folder/list.js | 264 ++++ folder/move.js | 163 ++ index.js | 204 +++ package-lock.json | 3129 +++++++++++++++++++++++++++++++++++++ package.json | 32 + rules/create.js | 249 +++ rules/index.js | 176 +++ rules/list.js | 202 +++ tools/get-email-thread.js | 164 ++ utils/bodyParser.js | 173 ++ utils/graph-api.js | 119 ++ utils/mock-data.js | 145 ++ utils/odata-helpers.js | 221 +++ utils/threadBuilder.js | 160 ++ 37 files changed, 7953 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 auth/index.js create mode 100644 auth/token-manager.js create mode 100644 auth/tools.js create mode 100644 calendar/accept.js create mode 100644 calendar/cancel.js create mode 100644 calendar/create.js create mode 100644 calendar/decline.js create mode 100644 calendar/delete.js create mode 100644 calendar/index.js create mode 100644 calendar/list.js create mode 100644 config.js create mode 100644 email/folder-utils.js create mode 100644 email/index.js create mode 100644 email/list.js create mode 100644 email/read-multiple.js create mode 100644 email/read.js create mode 100644 email/search.js create mode 100644 email/send.js create mode 100644 folder/create.js create mode 100644 folder/index.js create mode 100644 folder/list.js create mode 100644 folder/move.js create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 rules/create.js create mode 100644 rules/index.js create mode 100644 rules/list.js create mode 100644 tools/get-email-thread.js create mode 100644 utils/bodyParser.js create mode 100644 utils/graph-api.js create mode 100644 utils/mock-data.js create mode 100644 utils/odata-helpers.js create mode 100644 utils/threadBuilder.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..09f1e1b --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Microsoft Azure App Registration +# Required: your app registration client ID +MS_CLIENT_ID=your_client_id_here + +# Optional: only needed for confidential client app registrations (client secret app regs). +# Leave blank or omit for public client apps (device code flow with "Allow public client flows" enabled). +# MS_CLIENT_SECRET= + +# Optional: Enable test mode (uses mock data instead of real API calls) +USE_TEST_MODE=false + +# Optional: Enable verbose debug logging +DEBUG_MODE=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ccd3df --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Dependency directories +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Tokens and credentials +*.pem +*.key +*.cert +*.token.json +.outlook-mcp-tokens.json + +# Editor directories and files +.idea/ +.vscode/ +*.swp +*.swo + +# OS specific files +.DS_Store +Thumbs.db + +# Logs +logs +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3a1bbd --- /dev/null +++ b/README.md @@ -0,0 +1,304 @@ +# Outlook MCP Server + +A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes Microsoft Outlook email, calendar, folders, and inbox rules through the Microsoft Graph API. + +Built for use with AI agents (Claude, Hermes, etc.) that support the MCP standard — gives your assistant the ability to read, search, and send email, manage calendar events, organize folders, and automate inbox rules. + +## Features + +- **Email**: List, search, read, send, and reconstruct full conversation threads with quoted-reply stripping and signature deduplication +- **Calendar**: List, create, decline, cancel, and delete events +- **Folders**: List (flat or hierarchical), create, and move emails between folders +- **Inbox Rules**: List, create, and reorder execution priority of inbox rules +- **Authentication**: MSAL device code flow with persistent token cache — no browser redirect needed +- **Test Mode**: Mock data layer for development without a real Microsoft account + +## Quick Start + +### Prerequisites + +- Node.js ≥ 14 +- A Microsoft Azure App Registration (see [Azure Setup](#azure-app-registration) below) + +### Install + +```bash +git clone outlook-mcp +cd outlook-mcp +npm install +``` + +### Configure + +```bash +cp .env.example .env +# Edit .env and set MS_CLIENT_ID +``` + +### Run + +```bash +npm start +``` + +The server communicates over stdio (standard MCP transport). Your MCP client (Claude Desktop, Hermes, etc.) launches it as a subprocess. + +### Authenticate + +When you first use the server, call the `authenticate` tool. It will return a URL and a short code: + +``` +1. Open: https://microsoft.com/devicelogin +2. Enter code: ABC123XYZ + +You have 15 minutes to complete sign-in. +``` + +Visit the URL on any device, enter the code, and sign in with your Microsoft account. Tokens are cached to disk (`~/.outlook-mcp-tokens.json`) and silently refreshed by MSAL — you won't need to re-authenticate until the refresh token expires. + +To check auth status without side effects, use `check-auth-status` (not `authenticate`). + +## Azure App Registration + +This server uses MSAL **device code flow** with a **public client** app registration. + +1. Go to [Azure Portal → App Registrations](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) → **New registration** +2. Name it (e.g. "Outlook MCP") +3. Supported account types: **Accounts in any organizational directory and personal Microsoft accounts** (multi-tenant) +4. Redirect URI: leave blank (device code flow doesn't need one) +5. After creation, go to **Authentication** → scroll to **Advanced settings** → enable **Allow public client flows** +6. Go to **API permissions** → add **Delegated** Microsoft Graph permissions: + - `Mail.Read` + - `Mail.ReadWrite` + - `Mail.Send` + - `User.Read` + - `Calendars.Read` + - `Calendars.ReadWrite` + - `MailboxSettings.ReadWrite` + - `offline_access` +7. Copy the **Application (client) ID** — that's your `MS_CLIENT_ID` + +No client secret is needed for public client apps. + +## Configuration + +All configuration is via environment variables: + +| Variable | Required | Default | Description | +|---|---|---|---| +| `MS_CLIENT_ID` | Yes | — | Azure app registration client ID | +| `MS_CLIENT_SECRET` | No | — | Only for confidential client apps; leave blank for public client | +| `USE_TEST_MODE` | No | `false` | Use mock data instead of real API calls | +| `DEBUG_MODE` | No | `false` | Verbose logging (MSAL, API calls, working directory) | +| `OUTLOOK_TOKEN_STORE_PATH` | No | `~/.outlook-mcp-tokens.json` | Token cache file path (override for multi-instance setups) | + +## MCP Client Configuration + +### Claude Desktop + +Add to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "outlook": { + "command": "node", + "args": ["/path/to/outlook-mcp/index.js"], + "env": { + "MS_CLIENT_ID": "your-client-id-here" + } + } + } +} +``` + +### Hermes Agent + +Add to `config.yaml` under `mcp_servers`: + +```yaml +mcp_servers: + outlook-mcp: + command: node + args: + - /path/to/outlook-mcp/index.js + env: + MS_CLIENT_ID: your-client-id-here +``` + +### Multi-Account (Multiple Instances) + +The server is single-account by design — `getCachedAccount()` always returns the first cached account. To use multiple accounts (e.g. work + personal), run separate instances with isolated token caches: + +```yaml +mcp_servers: + outlook-mcp-work: + command: node + args: [/path/to/outlook-mcp/index.js] + env: + MS_CLIENT_ID: your-client-id + outlook-mcp-personal: + command: node + args: [/path/to/outlook-mcp/index.js] + env: + MS_CLIENT_ID: your-client-id + OUTLOOK_TOKEN_STORE_PATH: /path/to/.outlook-mcp-tokens-personal.json +``` + +Each instance gets its own MSAL cache — no account-selection conflicts. + +## Tools Reference + +The server exposes **20 tools** across five categories. + +### Authentication (3 tools) + +| Tool | Description | +|---|---| +| `about` | Returns server name and version | +| `authenticate` | Starts MSAL device code flow — returns a URL and code for the user to complete sign-in | +| `check-auth-status` | Checks if a valid cached token exists (no side effects) | + +### Email (6 tools) + +| Tool | Key Parameters | Description | +|---|---|---| +| `list-emails` | `folder`, `count`, `dateFrom`, `dateTo`, `dateRange` | Lists emails from a folder with date filtering. Results include `conversationId`. | +| `search-emails` | `query`, `from`, `to`, `subject`, `hasAttachments`, `unreadOnly`, `count` | Progressive search with KQL fallback strategies. Results include `conversationId`. | +| `read-email` | `id` | Reads a single email with full body (auto-cleaned HTML → text) | +| `read-emails` | `ids` (max 10) | Reads multiple emails concurrently | +| `send-email` | `to`, `cc`, `bcc`, `subject`, `body`, `importance`, `saveToSentItems` | Sends an email (plain text or HTML) | +| `get-email-thread` | `conversationId` (preferred) or `ids` (max 20) | Fetches a complete conversation across all folders (inbox + sent), strips quoted replies, deduplicates signatures per sender | + +**Date filtering** supports both ISO dates (`2024-06-15`) and relative ranges (`today`, `yesterday`, `last7days`, `last30days`, `thisweek`, `lastweek`, `thismonth`, `lastmonth`). + +**Search strategy**: The server uses a progressive fallback approach because Graph API doesn't allow `$search` with `$orderby` or `$filter`. It tries: (1) combined KQL search with client-side boolean filtering, (2) individual term searches, (3) boolean-filter-only with `$filter` + `$orderby`, (4) fallback to recent emails. + +### Calendar (5 tools) + +| Tool | Key Parameters | Description | +|---|---|---| +| `list-events` | `count`, `startDate`, `endDate` | Lists events in a date range (defaults to upcoming from now) | +| `create-event` | `subject`, `start`, `end`, `attendees`, `body` | Creates a calendar event (times in ISO 8601, UTC timezone) | +| `decline-event` | `eventId`, `comment` | Declines a meeting invitation | +| `cancel-event` | `eventId`, `comment` | Cancels an event you organized | +| `delete-event` | `eventId` | Deletes an event from your calendar | + +### Folders (3 tools) + +| Tool | Key Parameters | Description | +|---|---|---| +| `list-folders` | `includeItemCounts`, `includeChildren` | Lists mail folders (flat list or hierarchical tree) | +| `create-folder` | `name`, `parentFolder` | Creates a new mail folder (optionally nested under a parent) | +| `move-emails` | `emailIds`, `targetFolder`, `sourceFolder` | Moves emails to a target folder by name | + +### Inbox Rules (3 tools) + +| Tool | Key Parameters | Description | +|---|---|---| +| `list-rules` | `includeDetails` | Lists inbox rules sorted by execution order (sequence number) | +| `create-rule` | `name`, `fromAddresses`, `containsSubject`, `hasAttachments`, `moveToFolder`, `markAsRead`, `isEnabled`, `sequence` | Creates a new inbox rule | +| `edit-rule-sequence` | `ruleName`, `sequence` | Changes the execution order of an existing rule | + +## Architecture + +``` +index.js # Main entry point — MCP server, tool registration, request routing +config.js # Server config, Graph API endpoints, field selections, pagination limits +.env.example # Template for environment variables + +auth/ + index.js # ensureAuthenticated() — the gatekeeper all tools call + token-manager.js # MSAL PublicClientApplication, device code flow, persistent cache plugin + tools.js # authenticate, check-auth-status, about tool handlers + +email/ + index.js # Tool definitions (schemas) for email tools + list.js # list-emails handler + search.js # search-emails handler with progressive KQL fallback + read.js # read-email handler + read-multiple.js # read-emails handler (concurrent fetch) + send.js # send-email handler + folder-utils.js # resolveFolderPath(), getFolderIdByName(), getAllFolders() — shared utilities + +calendar/ + index.js # Tool definitions for calendar tools + list.js # list-events handler + create.js # create-event handler + decline.js # decline-event handler + cancel.js # cancel-event handler + delete.js # delete-event handler + accept.js # ⚠️ Dead file — not registered (see Known Issues) + +folder/ + index.js # Tool definitions for folder tools + list.js # list-folders handler (flat + hierarchical formatting) + create.js # create-folder handler + move.js # move-emails handler + +rules/ + index.js # Tool definitions for rules tools + edit-rule-sequence handler + list.js # list-rules handler + getInboxRules() utility + create.js # create-rule handler + +tools/ + get-email-thread.js # get-email-thread tool — conversationId-based thread fetcher + +utils/ + graph-api.js # callGraphAPI() — HTTPS client for Microsoft Graph + bodyParser.js # HTML → text conversion, boilerplate/banner/legal block stripping, signature handling + threadBuilder.js # Quote-boundary detection, unique-content extraction, chronological thread formatting + odata-helpers.js # OData filter building, date parsing, relative date ranges + mock-data.js # Test mode mock responses +``` + +### Key Design Decisions + +**Token persistence**: MSAL's cache plugin serializes/deserializes the token cache to disk on every access, so tokens survive process restarts. The cache file is plain JSON but contains MSAL's internal format — don't edit it manually. + +**Body cleaning**: All email bodies pass through `bodyParser.cleanBody()` which: (1) converts HTML to text, (2) decodes HTML entities, (3) strips external-email caution banners, (4) strips legal boilerplate (DISCLAIMER / CONFIDENTIALITY NOTICE blocks), (5) optionally strips signature blocks (Outlook `id="Signature"` divs or text delimiters). + +**Thread reconstruction**: `get-email-thread` fetches all messages matching a `conversationId` across all folders (inbox + sent items), sorts chronologically in memory (because `$filter` + `$orderby` on `conversationId` causes a Graph API 400), strips quoted replies using pattern matching (Outlook-style "From:/Sent:" headers, Gmail-style "On [date] [name] wrote:", `>`-prefixed lines, forwarded message blocks), and deduplicates signatures per sender (first occurrence keeps signature, repeats strip it). + +**Progressive search**: Graph API doesn't allow `$search` with `$orderby` or `$filter`. The search handler tries multiple strategies in order and applies boolean filters (unread, attachments) client-side when needed. + +**No external HTTP framework**: `graph-api.js` uses Node's native `https` module directly — no axios, no node-fetch, no dependencies beyond MSAL and the MCP SDK. + +## Development + +### Test Mode + +```bash +npm run test-mode +``` + +Sets `USE_TEST_MODE=true` — the server returns mock data for all API calls instead of hitting Graph API. Useful for testing tool schemas and response formatting without a real Microsoft account. + +### MCP Inspector + +```bash +npm run inspect +``` + +Launches the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) — a web UI for interactively testing MCP tools. + +## Security Notes + +- **Token cache**: The `.outlook-mcp-tokens.json` file contains access and refresh tokens. It's in `.gitignore` by default. Protect it like a password. +- **No client secret in code**: For public client apps, no secret is needed. If you use a confidential client app, set `MS_CLIENT_SECRET` in the environment (not in the code). +- **Scopes are hardcoded**: The OAuth scopes are defined in `config.js` and include read/write for mail and calendar. Adjust if you need fewer permissions. +- **`.gitignore` covers**: `node_modules/`, `.env*`, `*.token.json`, `*.pem`, `*.key`, `.outlook-mcp-tokens.json` + +## Known Issues + +1. **`calendar/accept.js` is not registered** — The `accept-event` handler exists in `calendar/accept.js` but is not imported or registered in `calendar/index.js`. The tool is unavailable to MCP clients. To fix: import and add it to `calendarTools`. + +2. **`rules/index.js` missing `callGraphAPI` import** — `handleEditRuleSequence` calls `callGraphAPI()` but the function is never imported at the top of the file. This will throw a `ReferenceError` at runtime when `edit-rule-sequence` is invoked. To fix: add `const { callGraphAPI } = require('../utils/graph-api');` to `rules/index.js`. + +3. **Calendar events are created in UTC** — `create-event` hardcodes `timeZone: "UTC"` for both start and end. Events created through the server will be in UTC regardless of the user's local timezone. + +4. **`authenticate` `force` parameter is a no-op** — The `force` parameter is declared in the tool schema but never read by the handler. Calling `authenticate(force=false)` will still start a fresh device code flow. Use `check-auth-status` to check auth state without side effects. + +## License + +MIT \ No newline at end of file diff --git a/auth/index.js b/auth/index.js new file mode 100644 index 0000000..4923eae --- /dev/null +++ b/auth/index.js @@ -0,0 +1,31 @@ +/** + * Authentication module for Outlook MCP server + */ +const tokenManager = require('./token-manager'); +const { authTools } = require('./tools'); + +/** + * Ensures the user is authenticated and returns an access token + * @param {boolean} forceNew - Whether to force a new authentication + * @returns {Promise} - Access token + * @throws {Error} - If authentication fails + */ +async function ensureAuthenticated(forceNew = false) { + if (forceNew) { + throw new Error('Authentication required'); + } + + // MSAL's acquireTokenSilent handles both cache lookup and silent refresh automatically. + const accessToken = await tokenManager.getAccessToken(); + if (accessToken) { + return accessToken; + } + + throw new Error('Authentication required'); +} + +module.exports = { + tokenManager, + authTools, + ensureAuthenticated +}; diff --git a/auth/token-manager.js b/auth/token-manager.js new file mode 100644 index 0000000..45c499c --- /dev/null +++ b/auth/token-manager.js @@ -0,0 +1,166 @@ +/** + * Token management using @azure/msal-node + */ +const msal = require('@azure/msal-node'); +const fs = require('fs'); +const path = require('path'); +const config = require('../config'); + +// Persist MSAL's internal token cache to disk so tokens survive process restarts. +const cachePlugin = { + beforeCacheAccess: async (cacheContext) => { + try { + if (fs.existsSync(config.AUTH_CONFIG.tokenStorePath)) { + cacheContext.tokenCache.deserialize( + fs.readFileSync(config.AUTH_CONFIG.tokenStorePath, 'utf8') + ); + } + } catch (e) { + console.error('[TokenManager] Cache read error:', e.message); + } + }, + afterCacheAccess: async (cacheContext) => { + if (cacheContext.cacheHasChanged) { + try { + const dir = path.dirname(config.AUTH_CONFIG.tokenStorePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + config.AUTH_CONFIG.tokenStorePath, + cacheContext.tokenCache.serialize() + ); + console.error('[TokenManager] Cache saved to:', config.AUTH_CONFIG.tokenStorePath); + } catch (e) { + console.error('[TokenManager] Cache write error:', e.message); + } + } + } +}; + +// Singleton PublicClientApplication — device code flow requires a public client. +// "Allow public client flows" must be enabled in the app registration. +let _pca = null; +function getPca() { + if (!_pca) { + _pca = new msal.PublicClientApplication({ + auth: { + clientId: config.AUTH_CONFIG.clientId, + authority: 'https://login.microsoftonline.com/organizations' + }, + cache: { cachePlugin }, + system: { + loggerOptions: { + logLevel: msal.LogLevel.Warning, + piiLoggingEnabled: false, + loggerCallback: (level, message, containsPii) => { + if (!containsPii && config.DEBUG_MODE) { + console.error(`[MSAL] ${message}`); + } + } + } + } + }); + } + return _pca; +} + +/** + * Returns the first cached account from MSAL's token cache, or null. + */ +async function getCachedAccount() { + try { + const accounts = await getPca().getTokenCache().getAllAccounts(); + return accounts && accounts.length > 0 ? accounts[0] : null; + } catch (e) { + console.error('[TokenManager] getAllAccounts error:', e.message); + return null; + } +} + +/** + * Returns a valid access token via MSAL silent flow (handles refresh automatically). + * Returns null if no cached account or silent acquire fails. + */ +async function getAccessToken() { + // Test mode shortcut + if (config.USE_TEST_MODE) { + try { + const raw = fs.readFileSync(config.AUTH_CONFIG.tokenStorePath, 'utf8'); + const data = JSON.parse(raw); + if (data._testMode) return data.access_token; + } catch { /* fall through */ } + return null; + } + + try { + const account = await getCachedAccount(); + if (!account) return null; + const result = await getPca().acquireTokenSilent({ + scopes: config.AUTH_CONFIG.scopes, + account + }); + return result ? result.accessToken : null; + } catch (e) { + console.error('[TokenManager] Silent token acquire failed:', e.message); + return null; + } +} + +/** + * Initiates the device code flow. + * Returns { deviceCodeInfo, tokenPromise } immediately after the code is issued. + * tokenPromise resolves when the user completes sign-in; MSAL handles all polling. + */ +async function initiateDeviceCodeFlow() { + const pca = getPca(); + + let resolveDeviceCode, rejectDeviceCode; + const deviceCodeInfoPromise = new Promise((resolve, reject) => { + resolveDeviceCode = resolve; + rejectDeviceCode = reject; + }); + + const tokenPromise = pca.acquireTokenByDeviceCode({ + scopes: config.AUTH_CONFIG.scopes, + deviceCodeCallback: (response) => { + console.error('[TokenManager] Device code issued:', response.userCode); + resolveDeviceCode(response); + } + }).catch((err) => { + // If the flow fails before the callback fires, reject the code promise too + rejectDeviceCode(err); + throw err; + }); + + const deviceCodeInfo = await deviceCodeInfoPromise; + return { deviceCodeInfo, tokenPromise }; +} + +/** + * Silent refresh — with MSAL this is handled transparently inside getAccessToken(). + */ +async function refreshAccessToken() { + return getAccessToken(); +} + +/** + * Creates test tokens for USE_TEST_MODE. + */ +function createTestTokens() { + const testData = { + _testMode: true, + access_token: 'test_access_token_' + Date.now(), + expires_at: Date.now() + 3600 * 1000 + }; + const dir = path.dirname(config.AUTH_CONFIG.tokenStorePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(config.AUTH_CONFIG.tokenStorePath, JSON.stringify(testData, null, 2)); +} + +module.exports = { + getPca, + getCachedAccount, + getAccessToken, + initiateDeviceCodeFlow, + refreshAccessToken, + createTestTokens +}; diff --git a/auth/tools.js b/auth/tools.js new file mode 100644 index 0000000..0abe786 --- /dev/null +++ b/auth/tools.js @@ -0,0 +1,113 @@ +/** + * Authentication-related tools for the Outlook MCP server + */ +const config = require('../config'); +const tokenManager = require('./token-manager'); + +async function handleAbout() { + return { + content: [{ + type: "text", + text: `Outlook Assistant MCP Server v${config.SERVER_VERSION}\n\nProvides access to Microsoft Outlook email, calendar, and contacts through Microsoft Graph API.` + }] + }; +} + +/** + * Initiates device code flow via MSAL. Returns the code and URL to the user + * immediately; MSAL polls internally until the user completes sign-in. + */ +async function handleAuthenticate(args) { + if (config.USE_TEST_MODE) { + tokenManager.createTestTokens(); + return { + content: [{ type: "text", text: 'Successfully authenticated with Microsoft Graph API (test mode)' }] + }; + } + + let flowResult; + try { + flowResult = await tokenManager.initiateDeviceCodeFlow(); + } catch (err) { + console.error('[authenticate] Failed to initiate device code flow:', err.message); + return { + content: [{ type: "text", text: `Failed to start authentication: ${err.message}` }] + }; + } + + const { deviceCodeInfo, tokenPromise } = flowResult; + const { userCode, verificationUri, expiresIn } = deviceCodeInfo; + const minutesRemaining = Math.floor(expiresIn / 60); + + // MSAL polls internally — just log completion when it resolves + tokenPromise + .then(result => { + if (result) console.error('[authenticate] Sign-in completed — MSAL cached tokens.'); + }) + .catch(err => { + console.error(`[authenticate] Device code flow ended: ${err.message}`); + }); + + return { + content: [{ + type: "text", + text: [ + `To sign in to Microsoft, please:`, + ``, + ` 1. Open: ${verificationUri}`, + ` 2. Enter code: ${userCode}`, + ``, + `You have ${minutesRemaining} minutes to complete sign-in.`, + `After signing in, call check-auth-status to confirm.` + ].join('\n') + }] + }; +} + +async function handleCheckAuthStatus() { + console.error(`[CHECK-AUTH-STATUS] Checking for valid token...`); + try { + const token = await tokenManager.getAccessToken(); + if (!token) { + console.error('[CHECK-AUTH-STATUS] No valid token found'); + return { content: [{ type: "text", text: "Not authenticated" }] }; + } + console.error('[CHECK-AUTH-STATUS] Valid token acquired'); + return { content: [{ type: "text", text: "Authenticated and ready" }] }; + } catch (e) { + console.error('[CHECK-AUTH-STATUS] Error:', e.message); + return { content: [{ type: "text", text: "Not authenticated" }] }; + } +} + +const authTools = [ + { + name: "about", + description: "Returns information about this Outlook Assistant server", + inputSchema: { type: "object", properties: {}, required: [] }, + handler: handleAbout + }, + { + name: "authenticate", + description: "Authenticate with Microsoft Graph API using device code flow. Returns a short code and URL. IMPORTANT: Show the user the code and URL — they must visit the URL on any device and enter the code to complete sign-in. After they sign in, call check-auth-status to confirm.", + inputSchema: { + type: "object", + properties: { + force: { + anyOf: [{ type: "boolean" }, { type: "string" }], + description: "Force re-authentication even if already authenticated" + } + }, + required: [] + }, + handler: handleAuthenticate + }, + { + name: "check-auth-status", + description: "Check the current authentication status with Microsoft Graph API", + inputSchema: { type: "object", properties: {}, required: [] }, + handler: handleCheckAuthStatus + } +]; + +module.exports = { authTools, handleAbout, handleAuthenticate, handleCheckAuthStatus }; diff --git a/calendar/accept.js b/calendar/accept.js new file mode 100644 index 0000000..1ec51bc --- /dev/null +++ b/calendar/accept.js @@ -0,0 +1,64 @@ +/** + * Accept event functionality + */ +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); + +/** + * Accept event handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleAcceptEvent(args) { + const { eventId, comment } = args; + + if (!eventId) { + return { + content: [{ + type: "text", + text: "Event ID is required to accept an event." + }] + }; + } + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Build API endpoint + const endpoint = `me/events/${eventId}/accept`; + + // Request body + const body = { + comment: comment || "Accepted via API" + }; + + // Make API call + await callGraphAPI(accessToken, 'POST', endpoint, body); + + return { + content: [{ + type: "text", + text: `Event with ID ${eventId} has been successfully accepted.` + }] + }; + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error accepting event: ${error.message}` + }] + }; + } +} + +module.exports = handleAcceptEvent; \ No newline at end of file diff --git a/calendar/cancel.js b/calendar/cancel.js new file mode 100644 index 0000000..d625c54 --- /dev/null +++ b/calendar/cancel.js @@ -0,0 +1,64 @@ +/** + * Cancel event functionality + */ +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); + +/** + * Cancel event handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleCancelEvent(args) { + const { eventId, comment } = args; + + if (!eventId) { + return { + content: [{ + type: "text", + text: "Event ID is required to cancel an event." + }] + }; + } + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Build API endpoint + const endpoint = `me/events/${eventId}/cancel`; + + // Request body + const body = { + comment: comment || "Cancelled via API" + }; + + // Make API call + await callGraphAPI(accessToken, 'POST', endpoint, body); + + return { + content: [{ + type: "text", + text: `Event with ID ${eventId} has been successfully cancelled.` + }] + }; + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error cancelling event: ${error.message}` + }] + }; + } +} + +module.exports = handleCancelEvent; \ No newline at end of file diff --git a/calendar/create.js b/calendar/create.js new file mode 100644 index 0000000..b09108e --- /dev/null +++ b/calendar/create.js @@ -0,0 +1,68 @@ +/** + * Create event functionality + */ +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); + +/** + * Create event handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleCreateEvent(args) { + const { subject, start, end, attendees, body } = args; + + if (!subject || !start || !end) { + return { + content: [{ + type: "text", + text: "Subject, start, and end times are required to create an event." + }] + }; + } + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Build API endpoint + const endpoint = `me/events`; + + // Request body + const bodyContent = { + subject, + start: { dateTime: start, timeZone: "UTC" }, + end: { dateTime: end, timeZone: "UTC" }, + attendees: attendees?.map(email => ({ emailAddress: { address: email }, type: "required" })), + body: { contentType: "HTML", content: body || "" } + }; + + // Make API call + const response = await callGraphAPI(accessToken, 'POST', endpoint, bodyContent); + + return { + content: [{ + type: "text", + text: `Event '${subject}' has been successfully created.` + }] + }; + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error creating event: ${error.message}` + }] + }; + } +} + +module.exports = handleCreateEvent; \ No newline at end of file diff --git a/calendar/decline.js b/calendar/decline.js new file mode 100644 index 0000000..c4b0f96 --- /dev/null +++ b/calendar/decline.js @@ -0,0 +1,64 @@ +/** + * Decline event functionality + */ +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); + +/** + * Decline event handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleDeclineEvent(args) { + const { eventId, comment } = args; + + if (!eventId) { + return { + content: [{ + type: "text", + text: "Event ID is required to decline an event." + }] + }; + } + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Build API endpoint + const endpoint = `me/events/${eventId}/decline`; + + // Request body + const body = { + comment: comment || "Declined via API" + }; + + // Make API call + await callGraphAPI(accessToken, 'POST', endpoint, body); + + return { + content: [{ + type: "text", + text: `Event with ID ${eventId} has been successfully declined.` + }] + }; + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error declining event: ${error.message}` + }] + }; + } +} + +module.exports = handleDeclineEvent; \ No newline at end of file diff --git a/calendar/delete.js b/calendar/delete.js new file mode 100644 index 0000000..655ffbf --- /dev/null +++ b/calendar/delete.js @@ -0,0 +1,59 @@ +/** + * Delete event functionality + */ +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); + +/** + * Delete event handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleDeleteEvent(args) { + const { eventId } = args; + + if (!eventId) { + return { + content: [{ + type: "text", + text: "Event ID is required to delete an event." + }] + }; + } + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Build API endpoint + const endpoint = `me/events/${eventId}`; + + // Make API call + await callGraphAPI(accessToken, 'DELETE', endpoint); + + return { + content: [{ + type: "text", + text: `Event with ID ${eventId} has been successfully deleted.` + }] + }; + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error deleting event: ${error.message}` + }] + }; + } +} + +module.exports = handleDeleteEvent; \ No newline at end of file diff --git a/calendar/index.js b/calendar/index.js new file mode 100644 index 0000000..2a8d082 --- /dev/null +++ b/calendar/index.js @@ -0,0 +1,131 @@ +/** + * Calendar module for Outlook MCP server + */ +const handleListEvents = require('./list'); +const handleDeclineEvent = require('./decline'); +const handleCreateEvent = require('./create'); +const handleCancelEvent = require('./cancel'); +const handleDeleteEvent = require('./delete'); + +// Calendar tool definitions +const calendarTools = [ + { + name: "list-events", + description: "List calendar events. Defaults to upcoming events from now. Use startDate/endDate to query a specific date range including past events (e.g. to reconstruct what was scheduled on a given day).", + inputSchema: { + type: "object", + properties: { + count: { + anyOf: [{ type: "number" }, { type: "string" }], + description: "Number of events to retrieve (default: 10, max: 500). WARNING: Large counts may consume significant context tokens." + }, + startDate: { + type: "string", + description: "Start of date range (ISO 'YYYY-MM-DD' or full ISO 8601 datetime). Defaults to now if omitted." + }, + endDate: { + type: "string", + description: "End of date range (ISO 'YYYY-MM-DD' or full ISO 8601 datetime). To query a single day set startDate and endDate to the same date." + } + }, + required: [] + }, + handler: handleListEvents + }, + { + name: "decline-event", + description: "Declines a calendar event", + inputSchema: { + type: "object", + properties: { + eventId: { + type: "string", + description: "The ID of the event to decline" + }, + comment: { + type: "string", + description: "Optional comment for declining the event" + } + }, + required: ["eventId"] + }, + handler: handleDeclineEvent + }, + { + name: "create-event", + description: "Creates a new calendar event", + inputSchema: { + type: "object", + properties: { + subject: { + type: "string", + description: "The subject of the event" + }, + start: { + type: "string", + description: "The start time of the event in ISO 8601 format" + }, + end: { + type: "string", + description: "The end time of the event in ISO 8601 format" + }, + attendees: { + type: "array", + items: { + type: "string" + }, + description: "List of attendee email addresses" + }, + body: { + type: "string", + description: "Optional body content for the event" + } + }, + required: ["subject", "start", "end"] + }, + handler: handleCreateEvent + }, + { + name: "cancel-event", + description: "Cancels a calendar event", + inputSchema: { + type: "object", + properties: { + eventId: { + type: "string", + description: "The ID of the event to cancel" + }, + comment: { + type: "string", + description: "Optional comment for cancelling the event" + } + }, + required: ["eventId"] + }, + handler: handleCancelEvent + }, + { + name: "delete-event", + description: "Deletes a calendar event", + inputSchema: { + type: "object", + properties: { + eventId: { + type: "string", + description: "The ID of the event to delete" + } + }, + required: ["eventId"] + }, + handler: handleDeleteEvent + } +]; + +module.exports = { + calendarTools, + handleListEvents, + handleDeclineEvent, + handleCreateEvent, + handleCancelEvent, + handleDeleteEvent +}; diff --git a/calendar/list.js b/calendar/list.js new file mode 100644 index 0000000..c8733c4 --- /dev/null +++ b/calendar/list.js @@ -0,0 +1,94 @@ +/** + * List events functionality + */ +const config = require('../config'); +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); + +/** + * List events handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleListEvents(args) { + const count = Math.min(parseInt(args.count, 10) || 10, config.MAX_RESULT_COUNT); + + // Resolve date range — accept plain YYYY-MM-DD or full ISO datetime + const toISO = (dateStr, endOfDay = false) => { + if (!dateStr) return null; + // If just a date (no T), append time component + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + return endOfDay ? `${dateStr}T23:59:59Z` : `${dateStr}T00:00:00Z`; + } + return new Date(dateStr).toISOString(); + }; + + const startISO = toISO(args.startDate) || new Date().toISOString(); + const endISO = toISO(args.endDate, true); + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Build API endpoint + let endpoint = 'me/events'; + + // Build date filter + const filterParts = [`start/dateTime ge '${startISO}'`]; + if (endISO) filterParts.push(`end/dateTime le '${endISO}'`); + + // Add query parameters + const queryParams = { + $top: count, + $orderby: 'start/dateTime', + $filter: filterParts.join(' and '), + $select: config.CALENDAR_SELECT_FIELDS + }; + + // Make API call + const response = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams); + + if (!response.value || response.value.length === 0) { + return { + content: [{ + type: "text", + text: "No calendar events found." + }] + }; + } + + // Format results + const eventList = response.value.map((event, index) => { + const startDate = new Date(event.start.dateTime).toLocaleString(event.start.timeZone); + const endDate = new Date(event.end.dateTime).toLocaleString(event.end.timeZone); + const location = event.location.displayName || 'No location'; + + return `${index + 1}. ${event.subject} - Location: ${location}\nStart: ${startDate}\nEnd: ${endDate}\nSubject: ${event.subject}\nSummary: ${event.bodyPreview}\nID: ${event.id}\n`; + }).join("\n"); + + return { + content: [{ + type: "text", + text: `Found ${response.value.length} events:\n\n${eventList}` + }] + }; + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error listing events: ${error.message}` + }] + }; + } +} + +module.exports = handleListEvents; diff --git a/config.js b/config.js new file mode 100644 index 0000000..edc7b86 --- /dev/null +++ b/config.js @@ -0,0 +1,49 @@ +/** + * Configuration for Outlook MCP Server + */ +const path = require('path'); +const os = require('os'); + +// Ensure we have a home directory path even if process.env.HOME is undefined +const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir() || '/tmp'; + +module.exports = { + // Server information + SERVER_NAME: "outlook-assistant-main", + SERVER_VERSION: "1.0.0", + + // Test mode setting + USE_TEST_MODE: process.env.USE_TEST_MODE === 'true', + + // Debug mode setting + DEBUG_MODE: process.env.DEBUG_MODE === 'true', + + // Authentication configuration + AUTH_CONFIG: { + clientId: process.env.MS_CLIENT_ID || '', + // Optional: set MS_CLIENT_SECRET for confidential client app registrations. + // Public client apps (Allow public client flows enabled, no secret) leave this blank. + clientSecret: process.env.MS_CLIENT_SECRET || '', + scopes: ['Mail.Read', 'Mail.ReadWrite', 'Mail.Send', 'User.Read', 'Calendars.Read', 'Calendars.ReadWrite', 'MailboxSettings.ReadWrite', 'offline_access'], + tokenStorePath: process.env.OUTLOOK_TOKEN_STORE_PATH || path.join(homeDir, '.outlook-mcp-tokens.json'), + // Device code flow: polling interval in seconds (Microsoft returns the recommended interval) + deviceCodePollingInterval: 5 + }, + + // Microsoft Graph API + GRAPH_API_ENDPOINT: 'https://graph.microsoft.com/v1.0/', + + // Calendar constants + CALENDAR_SELECT_FIELDS: 'id,subject,start,end,location,bodyPreview,isAllDay,recurrence,attendees', + + // Email constants + EMAIL_SELECT_FIELDS: 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,bodyPreview,hasAttachments,importance,isRead,conversationId', + EMAIL_DETAIL_FIELDS: 'id,subject,from,toRecipients,ccRecipients,bccRecipients,receivedDateTime,bodyPreview,body,hasAttachments,importance,isRead,internetMessageHeaders', + + // Calendar constants + CALENDAR_SELECT_FIELDS: 'id,subject,bodyPreview,start,end,location,organizer,attendees,isAllDay,isCancelled', + + // Pagination + DEFAULT_PAGE_SIZE: 25, + MAX_RESULT_COUNT: 500 +}; diff --git a/email/folder-utils.js b/email/folder-utils.js new file mode 100644 index 0000000..946232d --- /dev/null +++ b/email/folder-utils.js @@ -0,0 +1,171 @@ +/** + * Email folder utilities + */ +const { callGraphAPI } = require('../utils/graph-api'); + +/** + * Cache of folder information to reduce API calls + * Format: { userId: { folderName: { id, path } } } + */ +const folderCache = {}; + +/** + * Resolve a folder name to its endpoint path + * @param {string} accessToken - Access token + * @param {string} folderName - Folder name to resolve + * @returns {Promise} - Resolved endpoint path + */ +async function resolveFolderPath(accessToken, folderName) { + // Default to inbox if no folder specified + if (!folderName) { + return 'me/messages'; + } + + // Handle well-known folder names + const wellKnownFolders = { + 'inbox': 'me/messages', + 'drafts': 'me/mailFolders/drafts/messages', + 'sent': 'me/mailFolders/sentItems/messages', + 'deleted': 'me/mailFolders/deletedItems/messages', + 'junk': 'me/mailFolders/junkemail/messages', + 'archive': 'me/mailFolders/archive/messages' + }; + + // Check if it's a well-known folder (case-insensitive) + const lowerFolderName = folderName.toLowerCase(); + if (wellKnownFolders[lowerFolderName]) { + console.error(`Using well-known folder path for "${folderName}"`); + return wellKnownFolders[lowerFolderName]; + } + + try { + // Try to find the folder by name + const folderId = await getFolderIdByName(accessToken, folderName); + if (folderId) { + const path = `me/mailFolders/${folderId}/messages`; + console.error(`Resolved folder "${folderName}" to path: ${path}`); + return path; + } + + // If not found, fall back to inbox + console.error(`Couldn't find folder "${folderName}", falling back to inbox`); + return 'me/messages'; + } catch (error) { + console.error(`Error resolving folder "${folderName}": ${error.message}`); + return 'me/messages'; + } +} + +/** + * Get the ID of a mail folder by its name + * @param {string} accessToken - Access token + * @param {string} folderName - Name of the folder to find + * @returns {Promise} - Folder ID or null if not found + */ +async function getFolderIdByName(accessToken, folderName) { + try { + // First try with exact match filter + console.error(`Looking for folder with name "${folderName}"`); + const response = await callGraphAPI( + accessToken, + 'GET', + 'me/mailFolders', + null, + { $filter: `displayName eq '${folderName}'` } + ); + + if (response.value && response.value.length > 0) { + console.error(`Found folder "${folderName}" with ID: ${response.value[0].id}`); + return response.value[0].id; + } + + // If exact match fails, try to get all folders and do a case-insensitive comparison + console.error(`No exact match found for "${folderName}", trying case-insensitive search`); + const allFoldersResponse = await callGraphAPI( + accessToken, + 'GET', + 'me/mailFolders', + null, + { $top: 100 } + ); + + if (allFoldersResponse.value) { + const lowerFolderName = folderName.toLowerCase(); + const matchingFolder = allFoldersResponse.value.find( + folder => folder.displayName.toLowerCase() === lowerFolderName + ); + + if (matchingFolder) { + console.error(`Found case-insensitive match for "${folderName}" with ID: ${matchingFolder.id}`); + return matchingFolder.id; + } + } + + console.error(`No folder found matching "${folderName}"`); + return null; + } catch (error) { + console.error(`Error finding folder "${folderName}": ${error.message}`); + return null; + } +} + +/** + * Get all mail folders + * @param {string} accessToken - Access token + * @returns {Promise} - Array of folder objects + */ +async function getAllFolders(accessToken) { + try { + // Get top-level folders + const response = await callGraphAPI( + accessToken, + 'GET', + 'me/mailFolders', + null, + { + $top: 100, + $select: 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount' + } + ); + + if (!response.value) { + return []; + } + + // Get child folders for folders with children + const foldersWithChildren = response.value.filter(f => f.childFolderCount > 0); + + const childFolderPromises = foldersWithChildren.map(async (folder) => { + try { + const childResponse = await callGraphAPI( + accessToken, + 'GET', + `me/mailFolders/${folder.id}/childFolders`, + null, + { + $select: 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount' + } + ); + + return childResponse.value || []; + } catch (error) { + console.error(`Error getting child folders for "${folder.displayName}": ${error.message}`); + return []; + } + }); + + const childFolders = await Promise.all(childFolderPromises); + + // Combine top-level folders and all child folders + return [...response.value, ...childFolders.flat()]; + } catch (error) { + console.error(`Error getting all folders: ${error.message}`); + return []; + } +} + +module.exports = { + resolveFolderPath, + getFolderIdByName, + getAllFolders +}; diff --git a/email/index.js b/email/index.js new file mode 100644 index 0000000..a9779db --- /dev/null +++ b/email/index.js @@ -0,0 +1,168 @@ +/** + * Email module for Outlook MCP server + */ +const handleListEmails = require('./list'); +const handleSearchEmails = require('./search'); +const handleReadEmail = require('./read'); +const handleReadMultipleEmails = require('./read-multiple'); +const handleSendEmail = require('./send'); + +// Email tool definitions +const emailTools = [ + { + name: "list-emails", + description: "Lists recent emails from a folder. Results include 'conversationId' which can be passed to 'get-email-thread' to retrieve a full thread.", + inputSchema: { + type: "object", + properties: { + folder: { + type: "string", + description: "Email folder to list (e.g., 'inbox', 'sent', 'drafts', default: 'inbox')" + }, + count: { + anyOf: [{ type: "number" }, { type: "string" }], + description: "Number of emails to retrieve (default: 10, max: 500). WARNING: Large counts may consume significant context tokens." + }, + dateFrom: { + type: "string", + description: "Start date for email filtering (ISO format: 'YYYY-MM-DD' or relative: 'yesterday', 'last7days')" + }, + dateTo: { + type: "string", + description: "End date for email filtering (ISO format: 'YYYY-MM-DD' or relative: 'today', 'tomorrow')" + }, + dateRange: { + type: "string", + description: "Predefined date range ('today', 'yesterday', 'last7days', 'last30days', 'thisweek', 'lastweek', 'thismonth', 'lastmonth')" + } + }, + required: [] + }, + handler: handleListEmails + }, + { + name: "search-emails", + description: "Search for emails by sender, subject, keywords, or filters. Results include 'conversationId' — pass it to 'get-email-thread' to read the full thread. If no matching emails are found, use 'list-emails' to browse recent mail instead.", + inputSchema: { + type: "object", + properties: { + query: { + type: "string", + description: "Full-text search query (searches across subject, body, and sender)" + }, + folder: { + type: "string", + description: "Email folder to search in (default: 'inbox'). Use 'list-folders' to discover folder names." + }, + from: { + type: "string", + description: "Filter by sender — can be an email address or display name (e.g. 'john@example.com' or 'John')" + }, + to: { + type: "string", + description: "Filter by recipient email address or display name" + }, + subject: { + type: "string", + description: "Filter by subject line keywords (e.g. 'invoice', 'meeting notes')" + }, + hasAttachments: { + anyOf: [{ type: "boolean" }, { type: "string" }], + description: "Set true to return only emails that have attachments" + }, + unreadOnly: { + anyOf: [{ type: "boolean" }, { type: "string" }], + description: "Set true to return only unread emails" + }, + count: { + anyOf: [{ type: "number" }, { type: "string" }], + description: "Number of results to return (default: 10, max: 500). WARNING: Large counts may consume significant context tokens." + } + }, + required: [] + }, + handler: handleSearchEmails + }, + { + name: "read-email", + description: "Reads the content of a specific email", + inputSchema: { + type: "object", + properties: { + id: { + type: "string", + description: "ID of the email to read" + } + }, + required: ["id"] + }, + handler: handleReadEmail + }, + { + name: "read-emails", + description: "Reads the content of multiple emails at once", + inputSchema: { + type: "object", + properties: { + ids: { + type: "array", + items: { + type: "string" + }, + description: "Array of email IDs to read (max: 10)" + } + }, + required: ["ids"] + }, + handler: handleReadMultipleEmails + }, + { + name: "send-email", + description: "Composes and sends a new email", + inputSchema: { + type: "object", + properties: { + to: { + type: "string", + description: "Comma-separated list of recipient email addresses" + }, + cc: { + type: "string", + description: "Comma-separated list of CC recipient email addresses" + }, + bcc: { + type: "string", + description: "Comma-separated list of BCC recipient email addresses" + }, + subject: { + type: "string", + description: "Email subject" + }, + body: { + type: "string", + description: "Email body content (can be plain text or HTML)" + }, + importance: { + type: "string", + description: "Email importance (normal, high, low)", + enum: ["normal", "high", "low"] + }, + saveToSentItems: { + type: "boolean", + description: "Whether to save the email to sent items" + } + }, + required: ["to", "subject", "body"] + }, + handler: handleSendEmail + } +]; + +module.exports = { + emailTools, + handleListEmails, + handleSearchEmails, + handleReadEmail, + handleReadMultipleEmails, + handleSendEmail +}; diff --git a/email/list.js b/email/list.js new file mode 100644 index 0000000..da06703 --- /dev/null +++ b/email/list.js @@ -0,0 +1,100 @@ +/** + * List emails functionality + */ +const config = require('../config'); +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); +const { buildODataFilter, buildDateFilter } = require('../utils/odata-helpers'); +const { resolveFolderPath } = require('./folder-utils'); + +/** + * List emails handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleListEmails(args) { + const folder = args.folder || "inbox"; + const count = Math.min(parseInt(args.count, 10) || 10, config.MAX_RESULT_COUNT); + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Resolve folder path using the proper folder utilities + const endpoint = await resolveFolderPath(accessToken, folder); + + // Add query parameters + const queryParams = { + $top: count, + $orderby: 'receivedDateTime desc', + $select: config.EMAIL_SELECT_FIELDS + }; + + // Add date filtering if specified + const dateConditions = buildDateFilter(args.dateFrom, args.dateTo, args.dateRange); + if (dateConditions.length > 0) { + queryParams.$filter = buildODataFilter(dateConditions); + } + + // Make API call + const response = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams); + + if (!response.value || response.value.length === 0) { + return { + content: [{ + type: "text", + text: `No emails found in ${folder}.` + }] + }; + } + + // Format results + const emailList = response.value.map((email, index) => { + const sender = email.from ? email.from.emailAddress : { name: 'Unknown', address: 'unknown' }; + const date = new Date(email.receivedDateTime).toLocaleString(); + const readStatus = email.isRead ? '' : '[UNREAD] '; + const convLine = email.conversationId ? `ConversationID: ${email.conversationId}\n` : ''; + + return `${index + 1}. ${readStatus}${date} - From: ${sender.name} (${sender.address})\nSubject: ${email.subject}\nID: ${email.id}\n${convLine}`; + }).join("\n"); + + // Build result message with date filter info + let resultMessage = `Found ${response.value.length} emails in ${folder}`; + + if (args.dateRange) { + resultMessage += ` (${args.dateRange})`; + } else if (args.dateFrom || args.dateTo) { + const dateInfo = []; + if (args.dateFrom) dateInfo.push(`from: ${args.dateFrom}`); + if (args.dateTo) dateInfo.push(`to: ${args.dateTo}`); + resultMessage += ` (${dateInfo.join(', ')})`; + } + + resultMessage += `:\n\n${emailList}`; + + return { + content: [{ + type: "text", + text: resultMessage + }] + }; + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error listing emails: ${error.message}` + }] + }; + } +} + +module.exports = handleListEmails; diff --git a/email/read-multiple.js b/email/read-multiple.js new file mode 100644 index 0000000..4f7feaf --- /dev/null +++ b/email/read-multiple.js @@ -0,0 +1,155 @@ +/** + * Read multiple emails functionality + */ +const config = require('../config'); +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); +const { cleanBody } = require('../utils/bodyParser'); + +/** + * Format a single email for display + * @param {object} email - Email object from Graph API + * @param {string} emailId - Email ID for error context + * @returns {string} - Formatted email text + */ +function formatEmail(email, emailId) { + if (!email) { + return `Email ID ${emailId}: Not found or inaccessible`; + } + + try { + // Format sender, recipients, etc. + const sender = email.from ? `${email.from.emailAddress.name} (${email.from.emailAddress.address})` : 'Unknown'; + const to = email.toRecipients ? email.toRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None'; + const cc = email.ccRecipients && email.ccRecipients.length > 0 ? email.ccRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None'; + const bcc = email.bccRecipients && email.bccRecipients.length > 0 ? email.bccRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None'; + const date = new Date(email.receivedDateTime).toLocaleString(); + + // Extract and clean body content (cleanBody handles both HTML and plain text) + let body = ''; + if (email.body) { + body = cleanBody(email.body.content); + } else { + body = cleanBody(email.bodyPreview) || 'No content'; + } + + // Format the email + return `From: ${sender} +To: ${to} +${cc !== 'None' ? `CC: ${cc}\n` : ''}${bcc !== 'None' ? `BCC: ${bcc}\n` : ''}Subject: ${email.subject} +Date: ${date} +Importance: ${email.importance || 'normal'} +Has Attachments: ${email.hasAttachments ? 'Yes' : 'No'} + +${body}`; + } catch (error) { + return `Email ID ${emailId}: Error formatting email - ${error.message}`; + } +} + +/** + * Read multiple emails handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleReadMultipleEmails(args) { + const emailIds = args.ids; + + if (!emailIds || !Array.isArray(emailIds) || emailIds.length === 0) { + return { + content: [{ + type: "text", + text: "Email IDs array is required and must contain at least one ID." + }] + }; + } + + // Limit the number of emails to prevent overwhelming responses + const maxEmails = 10; + if (emailIds.length > maxEmails) { + return { + content: [{ + type: "text", + text: `Too many email IDs provided. Maximum allowed is ${maxEmails}, but ${emailIds.length} were provided.` + }] + }; + } + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Create concurrent API calls for all email IDs + const emailPromises = emailIds.map(async (emailId) => { + try { + const endpoint = `me/messages/${encodeURIComponent(emailId)}`; + const queryParams = { + $select: config.EMAIL_DETAIL_FIELDS + }; + + const email = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams); + return { emailId, email, error: null }; + } catch (error) { + console.error(`Error reading email ${emailId}: ${error.message}`); + return { emailId, email: null, error: error.message }; + } + }); + + // Wait for all API calls to complete + const results = await Promise.all(emailPromises); + + // Format all emails + const formattedEmails = results.map((result, index) => { + const emailNumber = index + 1; + const separator = "=".repeat(80); + + if (result.error) { + return `${separator} +EMAIL ${emailNumber} (ID: ${result.emailId}) +${separator} +Error: ${result.error}`; + } else { + const formattedEmail = formatEmail(result.email, result.emailId); + return `${separator} +EMAIL ${emailNumber} (ID: ${result.emailId}) +${separator} +${formattedEmail}`; + } + }); + + // Count successful vs failed reads + const successCount = results.filter(r => !r.error && r.email).length; + const errorCount = results.filter(r => r.error || !r.email).length; + + const summary = `Retrieved ${successCount} email(s) successfully${errorCount > 0 ? `, ${errorCount} failed` : ''}. + +`; + + return { + content: [ + { + type: "text", + text: summary + formattedEmails.join('\n\n') + } + ] + }; + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error accessing emails: ${error.message}` + }] + }; + } +} + +module.exports = handleReadMultipleEmails; \ No newline at end of file diff --git a/email/read.js b/email/read.js new file mode 100644 index 0000000..423b533 --- /dev/null +++ b/email/read.js @@ -0,0 +1,126 @@ +/** + * Read email functionality + */ +const config = require('../config'); +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); +const { cleanBody } = require('../utils/bodyParser'); + +/** + * Read email handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleReadEmail(args) { + const emailId = args.id; + + if (!emailId) { + return { + content: [{ + type: "text", + text: "Email ID is required." + }] + }; + } + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Make API call to get email details + const endpoint = `me/messages/${encodeURIComponent(emailId)}`; + const queryParams = { + $select: config.EMAIL_DETAIL_FIELDS + }; + + try { + const email = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams); + + if (!email) { + return { + content: [ + { + type: "text", + text: `Email with ID ${emailId} not found.` + } + ] + }; + } + + // Format sender, recipients, etc. + const sender = email.from ? `${email.from.emailAddress.name} (${email.from.emailAddress.address})` : 'Unknown'; + const to = email.toRecipients ? email.toRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None'; + const cc = email.ccRecipients && email.ccRecipients.length > 0 ? email.ccRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None'; + const bcc = email.bccRecipients && email.bccRecipients.length > 0 ? email.bccRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None'; + const date = new Date(email.receivedDateTime).toLocaleString(); + + // Extract and clean body content (cleanBody handles both HTML and plain text) + let body = ''; + if (email.body) { + body = cleanBody(email.body.content); + } else { + body = cleanBody(email.bodyPreview) || 'No content'; + } + + // Format the email + const formattedEmail = `From: ${sender} +To: ${to} +${cc !== 'None' ? `CC: ${cc}\n` : ''}${bcc !== 'None' ? `BCC: ${bcc}\n` : ''}Subject: ${email.subject} +Date: ${date} +Importance: ${email.importance || 'normal'} +Has Attachments: ${email.hasAttachments ? 'Yes' : 'No'} + +${body}`; + + return { + content: [ + { + type: "text", + text: formattedEmail + } + ] + }; + } catch (error) { + console.error(`Error reading email: ${error.message}`); + + // Improved error handling with more specific messages + if (error.message.includes("doesn't belong to the targeted mailbox")) { + return { + content: [ + { + type: "text", + text: `The email ID seems invalid or doesn't belong to your mailbox. Please try with a different email ID.` + } + ] + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to read email: ${error.message}` + } + ] + }; + } + } + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error accessing email: ${error.message}` + }] + }; + } +} + +module.exports = handleReadEmail; diff --git a/email/search.js b/email/search.js new file mode 100644 index 0000000..046247c --- /dev/null +++ b/email/search.js @@ -0,0 +1,257 @@ +/** + * Improved search emails functionality + */ +const config = require('../config'); +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); +const { resolveFolderPath } = require('./folder-utils'); + +/** + * Search emails handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleSearchEmails(args) { + const folder = args.folder || "inbox"; + // Coerce count — MCP hosts may send numbers as strings + const count = Math.min(parseInt(args.count, 10) || 10, config.MAX_RESULT_COUNT); + const query = args.query || ''; + const from = args.from || ''; + const to = args.to || ''; + const subject = args.subject || ''; + // Coerce booleans — MCP hosts may send "true"/"false" as strings + const hasAttachments = args.hasAttachments === true || args.hasAttachments === 'true' ? true : undefined; + const unreadOnly = args.unreadOnly === true || args.unreadOnly === 'true' ? true : undefined; + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Resolve the folder path + const endpoint = await resolveFolderPath(accessToken, folder); + console.error(`Using endpoint: ${endpoint} for folder: ${folder}`); + + // Execute progressive search + const response = await progressiveSearch( + endpoint, + accessToken, + { query, from, to, subject }, + { hasAttachments, unreadOnly }, + count + ); + + return formatSearchResults(response); + } catch (error) { + // Handle authentication errors + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + // General error response + return { + content: [{ + type: "text", + text: `Error searching emails: ${error.message}` + }] + }; + } +} + +/** + * Execute a search with progressively simpler fallback strategies. + * + * Microsoft Graph API constraints on /me/messages: + * - $search and $orderby CANNOT be used together (causes 400) + * - $search and $filter CANNOT be used together (causes 400) + * - $filter and $orderby CAN be used together + * + * Strategy: + * 1. Text terms present → $search with proper KQL (no $orderby, no $filter), + * then apply boolean filters client-side + * 2. Text terms present → retry with each term individually (same approach) + * 3. Only boolean filters → $filter + $orderby (fully supported) + * 4. Fallback → recent emails (labeled in response) + */ +async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms, count) { + const hasTextTerms = !!(searchTerms.query || searchTerms.from || searchTerms.to || searchTerms.subject); + const hasBooleanFilters = filterTerms.hasAttachments === true || filterTerms.unreadOnly === true; + + // 1. Try combined KQL search (text terms only — boolean filters applied client-side) + if (hasTextTerms) { + try { + const kqlQuery = buildKqlQuery(searchTerms); + const params = { + $top: count, + $select: config.EMAIL_SELECT_FIELDS, + $search: kqlQuery + // NOTE: NO $orderby — not allowed with $search + // NOTE: NO $filter — not allowed with $search + }; + + console.error(`Attempting combined KQL search: ${kqlQuery}`); + const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params); + + if (response.value && response.value.length > 0) { + const filtered = applyClientSideFilters(response.value, filterTerms); + console.error(`Combined search found ${response.value.length} results, ${filtered.length} after filtering`); + if (filtered.length > 0) { + return { value: filtered }; + } + } + } catch (error) { + console.error(`Combined KQL search failed: ${error.message}`); + } + + // 2. Try each search term individually (priority: subject → from → to → query) + const termPriority = ['subject', 'from', 'to', 'query']; + for (const term of termPriority) { + if (!searchTerms[term]) continue; + + try { + const kqlQuery = buildSingleTermKql(term, searchTerms[term]); + const params = { + $top: count, + $select: config.EMAIL_SELECT_FIELDS, + $search: kqlQuery + // NOTE: NO $orderby, NO $filter + }; + + console.error(`Attempting single-term search (${term}): ${kqlQuery}`); + const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params); + + if (response.value && response.value.length > 0) { + const filtered = applyClientSideFilters(response.value, filterTerms); + console.error(`Search on ${term} found ${response.value.length} results, ${filtered.length} after filtering`); + if (filtered.length > 0) { + return { value: filtered }; + } + } + } catch (error) { + console.error(`Single-term search (${term}) failed: ${error.message}`); + } + } + } + + // 3. Boolean filters only (no text search) — $filter + $orderby is supported + if (hasBooleanFilters) { + try { + const filterConditions = []; + if (filterTerms.hasAttachments === true) filterConditions.push('hasAttachments eq true'); + if (filterTerms.unreadOnly === true) filterConditions.push('isRead eq false'); + + const params = { + $top: count, + $select: config.EMAIL_SELECT_FIELDS, + $orderby: 'receivedDateTime desc', + $filter: filterConditions.join(' and ') + }; + + console.error(`Attempting boolean-filter-only search: ${params.$filter}`); + const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params); + console.error(`Boolean filter search found ${response.value?.length || 0} results`); + return response; + } catch (error) { + console.error(`Boolean filter search failed: ${error.message}`); + } + } + + // 4. Final fallback: recent emails + console.error("All search strategies exhausted, falling back to recent emails"); + const basicParams = { + $top: count, + $select: config.EMAIL_SELECT_FIELDS, + $orderby: 'receivedDateTime desc' + }; + + const response = await callGraphAPI(accessToken, 'GET', endpoint, null, basicParams); + console.error(`Fallback to recent emails found ${response.value?.length || 0} results`); + + response._searchFallback = true; + response._originalTerms = searchTerms; + return response; +} + +/** + * Build a KQL query string for all provided search terms. + * The entire expression must be wrapped in outer double quotes for Graph API. + * Example: "subject:invoice from:john@example.com" + */ +function buildKqlQuery(searchTerms) { + const parts = []; + + if (searchTerms.subject) parts.push(`subject:${searchTerms.subject}`); + if (searchTerms.from) parts.push(`from:${searchTerms.from}`); + if (searchTerms.to) parts.push(`to:${searchTerms.to}`); + if (searchTerms.query) parts.push(searchTerms.query); + + return `"${parts.join(' ')}"`; +} + +/** + * Build a KQL query for a single field term. + * Example: "from:john@example.com" + */ +function buildSingleTermKql(term, value) { + if (term === 'query') { + return `"${value}"`; + } + return `"${term}:${value}"`; +} + +/** + * Apply boolean filter conditions to an in-memory array of emails. + * Used after $search results are returned (since $search + $filter is not supported). + */ +function applyClientSideFilters(emails, filterTerms) { + return emails.filter(email => { + if (filterTerms.hasAttachments === true && !email.hasAttachments) return false; + if (filterTerms.unreadOnly === true && email.isRead !== false) return false; + return true; + }); +} + +/** + * Format search results into a readable text format + * @param {object} response - The API response object + * @returns {object} - MCP response object + */ +function formatSearchResults(response) { + if (!response.value || response.value.length === 0) { + return { + content: [{ + type: "text", + text: `No emails found matching your search criteria.` + }] + }; + } + + // Format results + const emailList = response.value.map((email, index) => { + const sender = email.from?.emailAddress || { name: 'Unknown', address: 'unknown' }; + const date = new Date(email.receivedDateTime).toLocaleString(); + const readStatus = email.isRead ? '' : '[UNREAD] '; + const threadNote = email.conversationId ? `\nConversationID: ${email.conversationId}` : ''; + + return `${index + 1}. ${readStatus}${date} - From: ${sender.name} (${sender.address})\nSubject: ${email.subject}\nID: ${email.id}${threadNote}\n`; + }).join("\n"); + + // Add fallback warning if search had to give up + let additionalInfo = ''; + if (response._searchFallback) { + additionalInfo = `\n⚠️ Search could not find matches for the specified criteria — showing recent emails instead.`; + } + + return { + content: [{ + type: "text", + text: `Found ${response.value.length} emails:${additionalInfo}\n\n${emailList}` + }] + }; +} + +module.exports = handleSearchEmails; diff --git a/email/send.js b/email/send.js new file mode 100644 index 0000000..a8d65fc --- /dev/null +++ b/email/send.js @@ -0,0 +1,120 @@ +/** + * Send email functionality + */ +const config = require('../config'); +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); + +/** + * Send email handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleSendEmail(args) { + const { to, cc, bcc, subject, body, importance = 'normal', saveToSentItems = true } = args; + + // Validate required parameters + if (!to) { + return { + content: [{ + type: "text", + text: "Recipient (to) is required." + }] + }; + } + + if (!subject) { + return { + content: [{ + type: "text", + text: "Subject is required." + }] + }; + } + + if (!body) { + return { + content: [{ + type: "text", + text: "Body content is required." + }] + }; + } + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Format recipients + const toRecipients = to.split(',').map(email => { + email = email.trim(); + return { + emailAddress: { + address: email + } + }; + }); + + const ccRecipients = cc ? cc.split(',').map(email => { + email = email.trim(); + return { + emailAddress: { + address: email + } + }; + }) : []; + + const bccRecipients = bcc ? bcc.split(',').map(email => { + email = email.trim(); + return { + emailAddress: { + address: email + } + }; + }) : []; + + // Prepare email object + const emailObject = { + message: { + subject, + body: { + contentType: body.includes(' 0 ? ccRecipients : undefined, + bccRecipients: bccRecipients.length > 0 ? bccRecipients : undefined, + importance + }, + saveToSentItems + }; + + // Make API call to send email + await callGraphAPI(accessToken, 'POST', 'me/sendMail', emailObject); + + return { + content: [{ + type: "text", + text: `Email sent successfully!\n\nSubject: ${subject}\nRecipients: ${toRecipients.length}${ccRecipients.length > 0 ? ` + ${ccRecipients.length} CC` : ''}${bccRecipients.length > 0 ? ` + ${bccRecipients.length} BCC` : ''}\nMessage Length: ${body.length} characters` + }] + }; + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error sending email: ${error.message}` + }] + }; + } +} + +module.exports = handleSendEmail; diff --git a/folder/create.js b/folder/create.js new file mode 100644 index 0000000..b29e477 --- /dev/null +++ b/folder/create.js @@ -0,0 +1,124 @@ +/** + * Create folder functionality + */ +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); +const { getFolderIdByName } = require('../email/folder-utils'); + +/** + * Create folder handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleCreateFolder(args) { + const folderName = args.name; + const parentFolder = args.parentFolder || ''; + + if (!folderName) { + return { + content: [{ + type: "text", + text: "Folder name is required." + }] + }; + } + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Create folder with appropriate parent + const result = await createMailFolder(accessToken, folderName, parentFolder); + + return { + content: [{ + type: "text", + text: result.message + }] + }; + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error creating folder: ${error.message}` + }] + }; + } +} + +/** + * Create a new mail folder + * @param {string} accessToken - Access token + * @param {string} folderName - Name of the folder to create + * @param {string} parentFolderName - Name of the parent folder (optional) + * @returns {Promise} - Result object with status and message + */ +async function createMailFolder(accessToken, folderName, parentFolderName) { + try { + // Check if a folder with this name already exists + const existingFolder = await getFolderIdByName(accessToken, folderName); + if (existingFolder) { + return { + success: false, + message: `A folder named "${folderName}" already exists.` + }; + } + + // If parent folder specified, find its ID + let endpoint = 'me/mailFolders'; + if (parentFolderName) { + const parentId = await getFolderIdByName(accessToken, parentFolderName); + if (!parentId) { + return { + success: false, + message: `Parent folder "${parentFolderName}" not found. Please specify a valid parent folder or leave it blank to create at the root level.` + }; + } + + endpoint = `me/mailFolders/${parentId}/childFolders`; + } + + // Create the folder + const folderData = { + displayName: folderName + }; + + const response = await callGraphAPI( + accessToken, + 'POST', + endpoint, + folderData + ); + + if (response && response.id) { + const locationInfo = parentFolderName + ? `inside "${parentFolderName}"` + : "at the root level"; + + return { + success: true, + message: `Successfully created folder "${folderName}" ${locationInfo}.`, + folderId: response.id + }; + } else { + return { + success: false, + message: "Failed to create folder. The server didn't return a folder ID." + }; + } + } catch (error) { + console.error(`Error creating folder "${folderName}": ${error.message}`); + throw error; + } +} + +module.exports = handleCreateFolder; diff --git a/folder/index.js b/folder/index.js new file mode 100644 index 0000000..1dc73dc --- /dev/null +++ b/folder/index.js @@ -0,0 +1,78 @@ +/** + * Folder management module for Outlook MCP server + */ +const handleListFolders = require('./list'); +const handleCreateFolder = require('./create'); +const handleMoveEmails = require('./move'); + +// Folder management tool definitions +const folderTools = [ + { + name: "list-folders", + description: "Lists mail folders in your Outlook account", + inputSchema: { + type: "object", + properties: { + includeItemCounts: { + anyOf: [{ type: "boolean" }, { type: "string" }], + description: "Include counts of total and unread items" + }, + includeChildren: { + anyOf: [{ type: "boolean" }, { type: "string" }], + description: "Include child folders in hierarchy" + } + }, + required: [] + }, + handler: handleListFolders + }, + { + name: "create-folder", + description: "Creates a new mail folder", + inputSchema: { + type: "object", + properties: { + name: { + type: "string", + description: "Name of the folder to create" + }, + parentFolder: { + type: "string", + description: "Optional parent folder name (default is root)" + } + }, + required: ["name"] + }, + handler: handleCreateFolder + }, + { + name: "move-emails", + description: "Moves emails from one folder to another", + inputSchema: { + type: "object", + properties: { + emailIds: { + type: "string", + description: "Comma-separated list of email IDs to move" + }, + targetFolder: { + type: "string", + description: "Name of the folder to move emails to" + }, + sourceFolder: { + type: "string", + description: "Optional name of the source folder (default is inbox)" + } + }, + required: ["emailIds", "targetFolder"] + }, + handler: handleMoveEmails + } +]; + +module.exports = { + folderTools, + handleListFolders, + handleCreateFolder, + handleMoveEmails +}; diff --git a/folder/list.js b/folder/list.js new file mode 100644 index 0000000..5cca24f --- /dev/null +++ b/folder/list.js @@ -0,0 +1,264 @@ +/** + * List folders functionality + */ +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); + +/** + * List folders handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleListFolders(args) { + const includeItemCounts = args.includeItemCounts === true || args.includeItemCounts === 'true'; + const includeChildren = args.includeChildren === true || args.includeChildren === 'true'; + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Get all mail folders + const folders = await getAllFoldersHierarchy(accessToken, includeItemCounts); + + // If including children, format as hierarchy + if (includeChildren) { + return { + content: [{ + type: "text", + text: formatFolderHierarchy(folders, includeItemCounts) + }] + }; + } else { + // Otherwise, format as flat list + return { + content: [{ + type: "text", + text: formatFolderList(folders, includeItemCounts) + }] + }; + } + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error listing folders: ${error.message}` + }] + }; + } +} + +/** + * Get all mail folders with hierarchy information + * @param {string} accessToken - Access token + * @param {boolean} includeItemCounts - Include item counts in response + * @returns {Promise} - Array of folder objects with hierarchy + */ +async function getAllFoldersHierarchy(accessToken, includeItemCounts) { + try { + // Determine select fields based on whether to include counts + const selectFields = includeItemCounts + ? 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount' + : 'id,displayName,parentFolderId,childFolderCount'; + + // Get all mail folders + const response = await callGraphAPI( + accessToken, + 'GET', + 'me/mailFolders', + null, + { + $top: 100, + $select: selectFields + } + ); + + if (!response.value) { + return []; + } + + // Get child folders for folders with children + const foldersWithChildren = response.value.filter(f => f.childFolderCount > 0); + + const childFolderPromises = foldersWithChildren.map(async (folder) => { + try { + const childResponse = await callGraphAPI( + accessToken, + 'GET', + `me/mailFolders/${folder.id}/childFolders`, + null, + { $select: selectFields } + ); + + // Add parent folder info to each child + const childFolders = childResponse.value || []; + childFolders.forEach(child => { + child.parentFolder = folder.displayName; + }); + + return childFolders; + } catch (error) { + console.error(`Error getting child folders for "${folder.displayName}": ${error.message}`); + return []; + } + }); + + const childFolders = await Promise.all(childFolderPromises); + const allChildFolders = childFolders.flat(); + + // Add top-level flag to parent folders + const topLevelFolders = response.value.map(folder => ({ + ...folder, + isTopLevel: true + })); + + // Combine all folders + return [...topLevelFolders, ...allChildFolders]; + } catch (error) { + console.error(`Error getting all folders: ${error.message}`); + throw error; + } +} + +/** + * Format folders as a flat list + * @param {Array} folders - Array of folder objects + * @param {boolean} includeItemCounts - Whether to include item counts + * @returns {string} - Formatted list + */ +function formatFolderList(folders, includeItemCounts) { + if (!folders || folders.length === 0) { + return "No folders found."; + } + + // Sort folders alphabetically, with well-known folders first + const wellKnownFolderNames = ['Inbox', 'Drafts', 'Sent Items', 'Deleted Items', 'Junk Email', 'Archive']; + + const sortedFolders = [...folders].sort((a, b) => { + // Well-known folders come first + const aIsWellKnown = wellKnownFolderNames.includes(a.displayName); + const bIsWellKnown = wellKnownFolderNames.includes(b.displayName); + + if (aIsWellKnown && !bIsWellKnown) return -1; + if (!aIsWellKnown && bIsWellKnown) return 1; + + if (aIsWellKnown && bIsWellKnown) { + // Sort well-known folders by their index in the array + return wellKnownFolderNames.indexOf(a.displayName) - wellKnownFolderNames.indexOf(b.displayName); + } + + // Sort other folders alphabetically + return a.displayName.localeCompare(b.displayName); + }); + + // Format each folder + const folderLines = sortedFolders.map(folder => { + let folderInfo = folder.displayName; + + // Add parent folder info if available + if (folder.parentFolder) { + folderInfo += ` (in ${folder.parentFolder})`; + } + + // Add item counts if requested + if (includeItemCounts) { + const unreadCount = folder.unreadItemCount || 0; + const totalCount = folder.totalItemCount || 0; + folderInfo += ` - ${totalCount} items`; + + if (unreadCount > 0) { + folderInfo += ` (${unreadCount} unread)`; + } + } + + return folderInfo; + }); + + return `Found ${folders.length} folders:\n\n${folderLines.join('\n')}`; +} + +/** + * Format folders as a hierarchical tree + * @param {Array} folders - Array of folder objects + * @param {boolean} includeItemCounts - Whether to include item counts + * @returns {string} - Formatted hierarchy + */ +function formatFolderHierarchy(folders, includeItemCounts) { + if (!folders || folders.length === 0) { + return "No folders found."; + } + + // Build folder hierarchy + const folderMap = new Map(); + const rootFolders = []; + + // First pass: create map of all folders + folders.forEach(folder => { + folderMap.set(folder.id, { + ...folder, + children: [] + }); + + if (folder.isTopLevel) { + rootFolders.push(folder.id); + } + }); + + // Second pass: build hierarchy + folders.forEach(folder => { + if (!folder.isTopLevel && folder.parentFolderId) { + const parent = folderMap.get(folder.parentFolderId); + if (parent) { + parent.children.push(folder.id); + } else { + // Fallback for orphaned folders + rootFolders.push(folder.id); + } + } + }); + + // Format hierarchy recursively + function formatSubtree(folderId, level = 0) { + const folder = folderMap.get(folderId); + if (!folder) return ''; + + const indent = ' '.repeat(level); + let line = `${indent}${folder.displayName}`; + + // Add item counts if requested + if (includeItemCounts) { + const unreadCount = folder.unreadItemCount || 0; + const totalCount = folder.totalItemCount || 0; + line += ` - ${totalCount} items`; + + if (unreadCount > 0) { + line += ` (${unreadCount} unread)`; + } + } + + // Add children + const childLines = folder.children + .map(childId => formatSubtree(childId, level + 1)) + .filter(line => line.length > 0) + .join('\n'); + + return childLines.length > 0 ? `${line}\n${childLines}` : line; + } + + // Format all root folders + const formattedHierarchy = rootFolders + .map(folderId => formatSubtree(folderId)) + .join('\n'); + + return `Folder Hierarchy:\n\n${formattedHierarchy}`; +} + +module.exports = handleListFolders; diff --git a/folder/move.js b/folder/move.js new file mode 100644 index 0000000..f53864c --- /dev/null +++ b/folder/move.js @@ -0,0 +1,163 @@ +/** + * Move emails functionality + */ +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); +const { getFolderIdByName } = require('../email/folder-utils'); + +/** + * Move emails handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleMoveEmails(args) { + const emailIds = args.emailIds || ''; + const targetFolder = args.targetFolder || ''; + const sourceFolder = args.sourceFolder || ''; + + if (!emailIds) { + return { + content: [{ + type: "text", + text: "Email IDs are required. Please provide a comma-separated list of email IDs to move." + }] + }; + } + + if (!targetFolder) { + return { + content: [{ + type: "text", + text: "Target folder name is required." + }] + }; + } + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Parse email IDs + const ids = emailIds.split(',').map(id => id.trim()).filter(id => id); + + if (ids.length === 0) { + return { + content: [{ + type: "text", + text: "No valid email IDs provided." + }] + }; + } + + // Move emails + const result = await moveEmailsToFolder(accessToken, ids, targetFolder, sourceFolder); + + return { + content: [{ + type: "text", + text: result.message + }] + }; + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error moving emails: ${error.message}` + }] + }; + } +} + +/** + * Move emails to a folder + * @param {string} accessToken - Access token + * @param {Array} emailIds - Array of email IDs to move + * @param {string} targetFolderName - Name of the target folder + * @param {string} sourceFolderName - Name of the source folder (optional) + * @returns {Promise} - Result object with status and message + */ +async function moveEmailsToFolder(accessToken, emailIds, targetFolderName, sourceFolderName) { + try { + // Get the target folder ID + const targetFolderId = await getFolderIdByName(accessToken, targetFolderName); + if (!targetFolderId) { + return { + success: false, + message: `Target folder "${targetFolderName}" not found. Please specify a valid folder name.` + }; + } + + // Track successful and failed moves + const results = { + successful: [], + failed: [] + }; + + // Process each email one by one to handle errors independently + for (const emailId of emailIds) { + try { + // Move the email + await callGraphAPI( + accessToken, + 'POST', + `me/messages/${emailId}/move`, + { + destinationId: targetFolderId + } + ); + + results.successful.push(emailId); + } catch (error) { + console.error(`Error moving email ${emailId}: ${error.message}`); + results.failed.push({ + id: emailId, + error: error.message + }); + } + } + + // Generate result message + let message = ''; + + if (results.successful.length > 0) { + message += `Successfully moved ${results.successful.length} email(s) to "${targetFolderName}".`; + } + + if (results.failed.length > 0) { + if (message) message += '\n\n'; + message += `Failed to move ${results.failed.length} email(s). Errors:`; + + // Show first few errors with details + const maxErrors = Math.min(results.failed.length, 3); + for (let i = 0; i < maxErrors; i++) { + const failure = results.failed[i]; + message += `\n- Email ${i+1}: ${failure.error}`; + } + + // If there are more errors, just mention the count + if (results.failed.length > maxErrors) { + message += `\n...and ${results.failed.length - maxErrors} more.`; + } + } + + return { + success: results.successful.length > 0, + message, + results + }; + } catch (error) { + console.error(`Error in moveEmailsToFolder: ${error.message}`); + throw error; + } +} + +module.exports = handleMoveEmails; diff --git a/index.js b/index.js new file mode 100644 index 0000000..2925013 --- /dev/null +++ b/index.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node +/** + * Outlook MCP Server - Main entry point + * + * A Model Context Protocol server that provides access to + * Microsoft Outlook through the Microsoft Graph API. + * + * INSTRUCTIONS FOR AI MODELS: + * This server provides comprehensive Outlook integration with the following capabilities: + * + * 🔐 AUTHENTICATION (Required First): + * - Use `check-auth-status()` to verify authentication + * - Use `authenticate()` if not authenticated (follow the provided URL) + * + * 📧 EMAIL MANAGEMENT: + * - `list-emails()` - List emails with advanced date filtering. Results include conversationId. + * - `search-emails({ from, subject, query, unreadOnly, hasAttachments })` - Search emails. Results include conversationId. + * - `read-email({ id })` - Read full email content (body auto-cleaned) + * - `read-emails({ ids: [id1, id2] })` - Read multiple emails at once (max: 10, bodies auto-cleaned) + * - `get-email-thread({ conversationId })` - Fetch a complete deduplicated thread (quoted replies stripped). Use conversationId from list-emails or search-emails. Fallback: pass ids array of specific message IDs. + * - `send-email({ to, subject, body })` - Send new emails + * + * 💡 RECOMMENDED EMAIL WORKFLOW: + * 1. search-emails() or list-emails() → get conversationId from results + * 2. get-email-thread({ conversationId }) → read the full thread efficiently + * + * 📅 CALENDAR MANAGEMENT: + * - `list-events()` - List calendar events + * - `create-event({ subject, start, end })` - Create meetings + * - `decline-event()`, `cancel-event()` - Respond to invitations + * + * 📁 FOLDER MANAGEMENT: + * - `list-folders()` - List mail folders + * - `create-folder({ name })` - Create new folders + * - `move-emails({ emailIds, targetFolder })` - Organize emails + * + * 📋 EMAIL RULES: + * - `list-rules()` - List inbox rules + * - `create-rule({ name, conditions, actions })` - Automate email handling + * + * 💡 KEY FEATURES: + * - Date filtering: Use dateRange ("today", "last7days") or dateFrom/dateTo + * - High limits: Up to 500 emails/events (WARNING: may consume significant tokens) + * - Comprehensive search: Filter by sender, subject, attachments, read status + * - Full automation: Create rules for automatic email organization + * + * 📖 For complete documentation, see MCP_TOOLS_GUIDE.md + */ +const { Server } = require("@modelcontextprotocol/sdk/server/index.js"); +const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js"); +const config = require('./config'); +const { authTools } = require('./auth'); +const { calendarTools } = require('./calendar'); +const { emailTools } = require('./email'); +const { folderTools } = require('./folder'); +const { rulesTools } = require('./rules'); +const { threadTool } = require('./tools/get-email-thread'); + +// Log startup information +console.error(`STARTING ${config.SERVER_NAME.toUpperCase()} MCP SERVER`); +console.error(`Test mode is ${config.USE_TEST_MODE ? 'enabled' : 'disabled'}`); +if (config.DEBUG_MODE) { + console.error(`[DEBUG] Current Working Directory: ${process.cwd()}`); + console.error(`[DEBUG] MS_CLIENT_ID: ${process.env.MS_CLIENT_ID ? 'SET' : 'NOT SET'}`); + console.error(`[DEBUG] USE_TEST_MODE: ${process.env.USE_TEST_MODE ? 'SET' : 'NOT SET'}`); +} + +// Combine all tools +const TOOLS = [ + ...authTools, + ...calendarTools, + ...emailTools, + ...folderTools, + ...rulesTools, + threadTool +]; + +// Create server with tools capabilities +const server = new Server( + { name: config.SERVER_NAME, version: config.SERVER_VERSION }, + { + capabilities: { + tools: TOOLS.reduce((acc, tool) => { + acc[tool.name] = {}; + return acc; + }, {}) + } + } +); + +// Handle all requests +server.fallbackRequestHandler = async (request) => { + try { + const { method, params, id } = request; + console.error(`REQUEST: ${method} [${id}]`); + + // Initialize handler + if (method === "initialize") { + console.error(`INITIALIZE REQUEST: ID [${id}]`); + return { + protocolVersion: "2024-11-05", + capabilities: { + tools: TOOLS.reduce((acc, tool) => { + acc[tool.name] = {}; + return acc; + }, {}) + }, + serverInfo: { + name: config.SERVER_NAME, + version: config.SERVER_VERSION, + description: "Comprehensive Outlook integration with email, calendar, folders, and rules management. See MCP_TOOLS_GUIDE.md for complete documentation." + } + }; + } + + // Tools list handler + if (method === "tools/list") { + console.error(`TOOLS LIST REQUEST: ID [${id}]`); + console.error(`TOOLS COUNT: ${TOOLS.length}`); + console.error(`TOOLS NAMES: ${TOOLS.map(t => t.name).join(', ')}`); + + return { + tools: TOOLS.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema + })) + }; + } + + // Required empty responses for other capabilities + if (method === "resources/list") return { resources: [] }; + if (method === "prompts/list") return { prompts: [] }; + + // Tool call handler + if (method === "tools/call") { + try { + const { name, arguments: args = {} } = params || {}; + + console.error(`TOOL CALL: ${name}`); + + // Find the tool handler + const tool = TOOLS.find(t => t.name === name); + + if (tool && tool.handler) { + return await tool.handler(args); + } + + // Tool not found + return { + error: { + code: -32601, + message: `Tool not found: ${name}` + } + }; + } catch (error) { + console.error(`Error in tools/call:`, error); + return { + error: { + code: -32603, + message: `Error processing tool call: ${error.message}` + } + }; + } + } + + // For any other method, return method not found + return { + error: { + code: -32601, + message: `Method not found: ${method}` + } + }; + } catch (error) { + console.error(`Error in fallbackRequestHandler:`, error); + return { + error: { + code: -32603, + message: `Error processing request: ${error.message}` + } + }; + } +}; + +// Make the script executable +process.on('SIGTERM', () => { + console.error('SIGTERM received, exiting.'); + process.exit(0); +}); + +process.on('exit', () => { + // Any cleanup needed when the main server exits +}); + +// Start the server +const transport = new StdioServerTransport(); +server.connect(transport) + .then(() => { + console.error(`${config.SERVER_NAME} connected and listening`); + }) + .catch(error => { + console.error(`Connection error: ${error.message}`); + process.exit(1); + }); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0259cb2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3129 @@ +{ + "name": "outlook-mcp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "outlook-mcp", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@azure/msal-node": "^2.16.2", + "@modelcontextprotocol/sdk": "^1.1.0", + "dotenv": "^16.5.0" + }, + "devDependencies": { + "@modelcontextprotocol/inspector": "^0.10.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.3.tgz", + "integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@modelcontextprotocol/inspector": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/inspector/-/inspector-0.10.2.tgz", + "integrity": "sha512-P/ag1MJz7mdOpmlE5OUozehzw0AYDi1cuXUctcPBpNvbufpnmmIwpD79I0lN41ydH1vnH4c8MTork75KWmP/hQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "client", + "server", + "cli" + ], + "dependencies": { + "@modelcontextprotocol/inspector-cli": "^0.10.2", + "@modelcontextprotocol/inspector-client": "^0.10.2", + "@modelcontextprotocol/inspector-server": "^0.10.2", + "@modelcontextprotocol/sdk": "^1.10.0", + "concurrently": "^9.0.1", + "shell-quote": "^1.8.2", + "spawn-rx": "^5.1.2", + "ts-node": "^10.9.2", + "zod": "^3.23.8" + }, + "bin": { + "mcp-inspector": "cli/build/cli.js" + } + }, + "node_modules/@modelcontextprotocol/inspector-cli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/inspector-cli/-/inspector-cli-0.10.2.tgz", + "integrity": "sha512-PE5U1py8lj2TjgB705H6ENl/khQAjEskQ+HzfqGhYZ2xXO9DC7meJahbxa8NyEh5vLXweKc0Inj3tLtGpL88uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.10.0", + "commander": "^13.1.0", + "spawn-rx": "^5.1.2" + }, + "bin": { + "mcp-inspector-cli": "build/cli.js" + } + }, + "node_modules/@modelcontextprotocol/inspector-client": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/inspector-client/-/inspector-client-0.10.2.tgz", + "integrity": "sha512-gZfdkhtLEVjQslzKyyxTppm4iV2psuw6hkTtx+RULEopj+0dwE3mX4Ddl4uGcrz6eNv0bj/u7DE6azISIHSGXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.10.0", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-dialog": "^1.1.3", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.3", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-toast": "^1.2.6", + "@radix-ui/react-tooltip": "^1.1.8", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "cmdk": "^1.0.4", + "lucide-react": "^0.447.0", + "pkce-challenge": "^4.1.0", + "prismjs": "^1.30.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-simple-code-editor": "^0.14.1", + "serve-handler": "^6.1.6", + "tailwind-merge": "^2.5.3", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8" + }, + "bin": { + "mcp-inspector-client": "bin/start.js" + } + }, + "node_modules/@modelcontextprotocol/inspector-server": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/inspector-server/-/inspector-server-0.10.2.tgz", + "integrity": "sha512-VXXMIdOlzyZ0eGG22glkfMH1cWOoHqrgEN6QvFaleXvRSaCp5WVe3M2gLXOyHRFVHimrDWKsGB0NjeXxb6xYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.10.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "bin": { + "mcp-inspector-server": "build/index.js" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.1.tgz", + "integrity": "sha512-xNYdFdkJqEfIaTVP1gPKoEvluACHZsHZegIoICX8DM1o6Qf3G5u2BQJHmgd0n4YgRPqqK/u1ujQvrgAxxSJT9w==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.3", + "eventsource": "^3.0.2", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.4.tgz", + "integrity": "sha512-qz+fxrqgNxG0dYew5l7qR3c7wdgRu1XVUHGnGYX7rg5HM4p9SWaRmJwfgR3J0SgyUKayLmzQIun+N6rWRgiRKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.2.2.tgz", + "integrity": "sha512-pMxzQLK+m/tkDRXJg7VUjRx6ozsBdzNLOV4vexfVBU57qT2Gvf4cw2gKKhOohJxjadQ+WcUXCKosTIxcZzi03A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.3", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.4.tgz", + "integrity": "sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.10.tgz", + "integrity": "sha512-m6pZb0gEM5uHPSb+i2nKKGQi/HMSVjARMsLMWQfKDP+eJ6B+uqryHnXhpnohTWElw+vEcMk/o4wJODtdRKHwqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.7", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.4", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.6", + "@radix-ui/react-presence": "1.1.3", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.7.tgz", + "integrity": "sha512-j5+WBUdhccJsmH5/H0K6RncjDtoALSEr6jbkaZu+bjw6hOPOhHycr6vEUujl+HBK8kjUfWcoCJXxP6e4lUlMZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", + "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.4.tgz", + "integrity": "sha512-r2annK27lIW5w9Ho5NyQgqs0MmgZSTIKXWpVCJaLC1q2kZrZkcqnmHkCHMEmv8XLvsLlurKMPT+kbKkRkm/xVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.4.tgz", + "integrity": "sha512-wy3dqizZnZVV4ja0FNnUhIWNwWdoldXrneEyUcVtLYDAt8ovGS4ridtMAOGgXBBIfggL4BOveVWsjXDORdGEQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.10.tgz", + "integrity": "sha512-IZN7b3sXqajiPsOzKuNJBSP9obF4MX5/5UhTgWNofw4r1H+eATWb0SyMlaxPD/kzA4vadFgy1s7Z1AEJ6WMyHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.7", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.4", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.4", + "@radix-ui/react-portal": "1.1.6", + "@radix-ui/react-presence": "1.1.3", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.4.tgz", + "integrity": "sha512-3p2Rgm/a1cK0r/UVkx5F/K9v/EplfjAeIFCGOPYPO4lZ0jtg4iSQXt/YGTSLWaf4x7NG6Z4+uKFcylcTZjeqDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.6.tgz", + "integrity": "sha512-XmsIl2z1n/TsYFLIdYam2rmFwf9OC/Sh2avkbmVMDuBZIe7hSpM0cYnWPAo7nHOVx8zTuwDZGByfcqLdnzp3Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.3.tgz", + "integrity": "sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz", + "integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.7.tgz", + "integrity": "sha512-C6oAg451/fQT3EGbWHbCQjYTtbyjNO1uzQgMzwyivcHT3GKNEmu1q3UuREhN+HzHAVtv3ivMVK08QlC+PkYw9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.2.tgz", + "integrity": "sha512-HjkVHtBkuq+r3zUAZ/CvNWUGKPfuicGDbgtZgiQuFmNcV5F+Tgy24ep2nsAW2nFgvhGPJVqeBZa6KyVN0EyrBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.7", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.4", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.4", + "@radix-ui/react-portal": "1.1.6", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.8.tgz", + "integrity": "sha512-4iUaN9SYtG+/E+hJ7jRks/Nv90f+uAsRHbLYA6BcA9EsR6GNWgsvtS4iwU2SP0tOZfDGAyqIT0yz7ckgohEIFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.3", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-roving-focus": "1.1.7", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.10.tgz", + "integrity": "sha512-lVe1mQL8Di8KPQp62CDaLgttqyUGTchPuwDiCnaZz40HGxngJKB/fOJCHYxHZh2p1BtcuiPOYOKrxTVEmrnV5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.7", + "@radix-ui/react-portal": "1.1.6", + "@radix-ui/react-presence": "1.1.3", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.3.tgz", + "integrity": "sha512-0KX7jUYFA02np01Y11NWkk6Ip6TqMNmD4ijLelYAzeIndl2aVeltjJFJ2gwjNa1P8U/dgjQ+8cr9Y3Ni+ZNoRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.4", + "@radix-ui/react-portal": "1.1.6", + "@radix-ui/react-presence": "1.1.3", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.0.tgz", + "integrity": "sha512-rQj0aAWOpCdCMRbI6pLQm8r7S2BM3YhTa0SzOYD55k+hJA8oo9J+H+9wLM9oMlZWOX/wJWPTzfDfmZkf7LvCfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz", + "integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.1.2.tgz", + "integrity": "sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", + "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", + "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "0.447.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.447.0.tgz", + "integrity": "sha512-SZ//hQmvi+kDKrNepArVkYK7/jfeZ5uFNEnYmd45RKZcbGD78KLnrcNXmgeg6m+xNHFvTG+CblszXCy4n6DN4w==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/pkce-challenge": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz", + "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", + "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-simple-code-editor": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz", + "integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-handler": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz", + "integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.2", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-handler/node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-handler/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-handler/node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-handler/node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/serve-handler/node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", + "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/spawn-rx": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/spawn-rx/-/spawn-rx-5.1.2.tgz", + "integrity": "sha512-/y7tJKALVZ1lPzeZZB9jYnmtrL7d0N2zkorii5a7r7dhHkWIuLTzZpZzMJLK1dmYRgX/NCc4iarTO3F7BS2c/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7", + "rxjs": "^7.8.1" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", + "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz", + "integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/zod": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", + "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..41f3ada --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "outlook-mcp", + "version": "1.0.0", + "description": "MCP server for Claude to access Outlook data via Microsoft Graph API", + "main": "index.js", + "scripts": { + "start": "node index.js", + "auth-server": "node outlook-auth-server.js", + "test-mode": "USE_TEST_MODE=true node index.js", + "inspect": "npx @modelcontextprotocol/inspector node index.js" + }, + "keywords": [ + "claude", + "outlook", + "mcp", + "microsoft-graph", + "email" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@azure/msal-node": "^2.16.2", + "@modelcontextprotocol/sdk": "^1.1.0", + "dotenv": "^16.5.0" + }, + "devDependencies": { + "@modelcontextprotocol/inspector": "^0.10.2" + }, + "engines": { + "node": ">=14.0.0" + } +} diff --git a/rules/create.js b/rules/create.js new file mode 100644 index 0000000..f1ae93c --- /dev/null +++ b/rules/create.js @@ -0,0 +1,249 @@ +/** + * Create rule functionality + */ +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); +const { getFolderIdByName } = require('../email/folder-utils'); +const { getInboxRules } = require('./list'); + +/** + * Create rule handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleCreateRule(args) { + const { + name, + fromAddresses, + containsSubject, + moveToFolder, + } = args; + // Coerce booleans and numbers — MCP hosts may send these as strings + const hasAttachments = args.hasAttachments === true || args.hasAttachments === 'true' ? true : undefined; + const markAsRead = args.markAsRead === true || args.markAsRead === 'true' ? true : undefined; + const isEnabled = args.isEnabled === false || args.isEnabled === 'false' ? false : true; + const sequence = args.sequence !== undefined ? parseInt(args.sequence, 10) : undefined; + + // Add validation for sequence parameter + if (sequence !== undefined && (isNaN(sequence) || sequence < 1)) { + return { + content: [{ + type: "text", + text: "Sequence must be a positive number greater than zero." + }] + }; + } + + if (!name) { + return { + content: [{ + type: "text", + text: "Rule name is required." + }] + }; + } + + // Validate that at least one condition or action is specified + const hasCondition = fromAddresses || containsSubject || hasAttachments === true; + const hasAction = moveToFolder || markAsRead === true; + + if (!hasCondition) { + return { + content: [{ + type: "text", + text: "At least one condition is required. Specify fromAddresses, containsSubject, or hasAttachments." + }] + }; + } + + if (!hasAction) { + return { + content: [{ + type: "text", + text: "At least one action is required. Specify moveToFolder or markAsRead." + }] + }; + } + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Create rule + const result = await createInboxRule(accessToken, { + name, + fromAddresses, + containsSubject, + hasAttachments, + moveToFolder, + markAsRead, + isEnabled, + sequence + }); + + let responseText = result.message; + + // Add a tip about sequence if it wasn't provided + if (!sequence && !result.error) { + responseText += "\n\nTip: You can specify a 'sequence' parameter when creating rules to control their execution order. Lower sequence numbers run first."; + } + + return { + content: [{ + type: "text", + text: responseText + }] + }; + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error creating rule: ${error.message}` + }] + }; + } +} + +/** + * Create a new inbox rule + * @param {string} accessToken - Access token + * @param {object} ruleOptions - Rule creation options + * @returns {Promise} - Result object with status and message + */ +async function createInboxRule(accessToken, ruleOptions) { + try { + const { + name, + fromAddresses, + containsSubject, + hasAttachments, + moveToFolder, + markAsRead, + isEnabled, + sequence + } = ruleOptions; + + // Get existing rules to determine sequence if not provided + let ruleSequence = sequence; + if (!ruleSequence) { + try { + // Default to 100 if we can't get existing rules + ruleSequence = 100; + + // Get existing rules to find highest sequence + const existingRules = await getInboxRules(accessToken); + if (existingRules && existingRules.length > 0) { + // Find the highest sequence + const highestSequence = Math.max(...existingRules.map(r => r.sequence || 0)); + // Set new rule sequence to be higher + ruleSequence = Math.max(highestSequence + 1, 100); + console.error(`Auto-generated sequence: ${ruleSequence} (based on highest existing: ${highestSequence})`); + } + } catch (sequenceError) { + console.error(`Error determining rule sequence: ${sequenceError.message}`); + // Fall back to default value + ruleSequence = 100; + } + } + + console.error(`Using rule sequence: ${ruleSequence}`); + + // Make sure sequence is a positive integer + ruleSequence = Math.max(1, Math.floor(ruleSequence)); + + // Build rule object with sequence + const rule = { + displayName: name, + isEnabled: isEnabled === true, + sequence: ruleSequence, + conditions: {}, + actions: {} + }; + + // Add conditions + if (fromAddresses) { + // Parse email addresses + const emailAddresses = fromAddresses.split(',') + .map(email => email.trim()) + .filter(email => email) + .map(email => ({ + emailAddress: { + address: email + } + })); + + if (emailAddresses.length > 0) { + rule.conditions.fromAddresses = emailAddresses; + } + } + + if (containsSubject) { + rule.conditions.subjectContains = [containsSubject]; + } + + if (hasAttachments === true) { + rule.conditions.hasAttachment = true; + } + + // Add actions + if (moveToFolder) { + // Get folder ID + try { + const folderId = await getFolderIdByName(accessToken, moveToFolder); + if (!folderId) { + return { + success: false, + message: `Target folder "${moveToFolder}" not found. Please specify a valid folder name.` + }; + } + + rule.actions.moveToFolder = folderId; + } catch (folderError) { + console.error(`Error resolving folder "${moveToFolder}": ${folderError.message}`); + return { + success: false, + message: `Error resolving folder "${moveToFolder}": ${folderError.message}` + }; + } + } + + if (markAsRead === true) { + rule.actions.markAsRead = true; + } + + // Create the rule + const response = await callGraphAPI( + accessToken, + 'POST', + 'me/mailFolders/inbox/messageRules', + rule + ); + + if (response && response.id) { + return { + success: true, + message: `Successfully created rule "${name}" with sequence ${ruleSequence}.`, + ruleId: response.id + }; + } else { + return { + success: false, + message: "Failed to create rule. The server didn't return a rule ID." + }; + } + } catch (error) { + console.error(`Error creating rule: ${error.message}`); + throw error; + } +} + +module.exports = handleCreateRule; diff --git a/rules/index.js b/rules/index.js new file mode 100644 index 0000000..61898ba --- /dev/null +++ b/rules/index.js @@ -0,0 +1,176 @@ +/** + * Email rules management module for Outlook MCP server + */ +const handleListRules = require('./list'); +const handleCreateRule = require('./create'); + +// Import getInboxRules for the edit sequence tool +const { getInboxRules } = require('./list'); + +/** + * Edit rule sequence handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleEditRuleSequence(args) { + const { ruleName } = args; + const sequence = args.sequence !== undefined ? parseInt(args.sequence, 10) : undefined; + + if (!ruleName) { + return { + content: [{ + type: "text", + text: "Rule name is required. Please specify the exact name of an existing rule." + }] + }; + } + + if (!sequence || isNaN(sequence) || sequence < 1) { + return { + content: [{ + type: "text", + text: "A positive sequence number is required. Lower numbers run first (higher priority)." + }] + }; + } + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Get all rules + const rules = await getInboxRules(accessToken); + + // Find the rule by name + const rule = rules.find(r => r.displayName === ruleName); + if (!rule) { + return { + content: [{ + type: "text", + text: `Rule with name "${ruleName}" not found.` + }] + }; + } + + // Update the rule sequence + const updateResult = await callGraphAPI( + accessToken, + 'PATCH', + `me/mailFolders/inbox/messageRules/${rule.id}`, + { + sequence: sequence + } + ); + + return { + content: [{ + type: "text", + text: `Successfully updated the sequence of rule "${ruleName}" to ${sequence}.` + }] + }; + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error updating rule sequence: ${error.message}` + }] + }; + } +} + +// Rules management tool definitions +const rulesTools = [ + { + name: "list-rules", + description: "Lists inbox rules in your Outlook account", + inputSchema: { + type: "object", + properties: { + includeDetails: { + type: "boolean", + description: "Include detailed rule conditions and actions" + } + }, + required: [] + }, + handler: handleListRules + }, + { + name: "create-rule", + description: "Creates a new inbox rule", + inputSchema: { + type: "object", + properties: { + name: { + type: "string", + description: "Name of the rule to create" + }, + fromAddresses: { + type: "string", + description: "Comma-separated list of sender email addresses for the rule" + }, + containsSubject: { + type: "string", + description: "Subject text the email must contain" + }, + hasAttachments: { + anyOf: [{ type: "boolean" }, { type: "string" }], + description: "Whether the rule applies to emails with attachments" + }, + moveToFolder: { + type: "string", + description: "Name of the folder to move matching emails to" + }, + markAsRead: { + anyOf: [{ type: "boolean" }, { type: "string" }], + description: "Whether to mark matching emails as read" + }, + isEnabled: { + anyOf: [{ type: "boolean" }, { type: "string" }], + description: "Whether the rule should be enabled after creation (default: true)" + }, + sequence: { + anyOf: [{ type: "number" }, { type: "string" }], + description: "Order in which the rule is executed (lower numbers run first, default: 100)" + } + }, + required: ["name"] + }, + handler: handleCreateRule + }, + { + name: "edit-rule-sequence", + description: "Changes the execution order of an existing inbox rule", + inputSchema: { + type: "object", + properties: { + ruleName: { + type: "string", + description: "Name of the rule to modify" + }, + sequence: { + anyOf: [{ type: "number" }, { type: "string" }], + description: "New sequence value for the rule (lower numbers run first)" + } + }, + required: ["ruleName", "sequence"] + }, + handler: handleEditRuleSequence + } +]; + +module.exports = { + rulesTools, + handleListRules, + handleCreateRule, + handleEditRuleSequence +}; diff --git a/rules/list.js b/rules/list.js new file mode 100644 index 0000000..4d27bff --- /dev/null +++ b/rules/list.js @@ -0,0 +1,202 @@ +/** + * List rules functionality + */ +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); + +/** + * List rules handler + * @param {object} args - Tool arguments + * @returns {object} - MCP response + */ +async function handleListRules(args) { + const includeDetails = args.includeDetails === true; + + try { + // Get access token + const accessToken = await ensureAuthenticated(); + + // Get all inbox rules + const rules = await getInboxRules(accessToken); + + // Format the rules based on detail level + const formattedRules = formatRulesList(rules, includeDetails); + + return { + content: [{ + type: "text", + text: formattedRules + }] + }; + } catch (error) { + if (error.message === 'Authentication required') { + return { + content: [{ + type: "text", + text: "Authentication required. Please use the 'authenticate' tool first." + }] + }; + } + + return { + content: [{ + type: "text", + text: `Error listing rules: ${error.message}` + }] + }; + } +} + +/** + * Get all inbox rules + * @param {string} accessToken - Access token + * @returns {Promise} - Array of rule objects + */ +async function getInboxRules(accessToken) { + try { + const response = await callGraphAPI( + accessToken, + 'GET', + 'me/mailFolders/inbox/messageRules', + null + ); + + return response.value || []; + } catch (error) { + console.error(`Error getting inbox rules: ${error.message}`); + throw error; + } +} + +/** + * Format rules list for display + * @param {Array} rules - Array of rule objects + * @param {boolean} includeDetails - Whether to include detailed conditions and actions + * @returns {string} - Formatted rules list + */ +function formatRulesList(rules, includeDetails) { + if (!rules || rules.length === 0) { + return "No inbox rules found.\n\nTip: You can create rules using the 'create-rule' tool. Rules are processed in order of their sequence number (lower numbers are processed first)."; + } + + // Sort rules by sequence to show execution order + const sortedRules = [...rules].sort((a, b) => { + return (a.sequence || 9999) - (b.sequence || 9999); + }); + + // Format rules based on detail level + if (includeDetails) { + // Detailed format + const detailedRules = sortedRules.map((rule, index) => { + // Format rule header with sequence + let ruleText = `${index + 1}. ${rule.displayName}${rule.isEnabled ? '' : ' (Disabled)'} - Sequence: ${rule.sequence || 'N/A'}`; + + // Format conditions + const conditions = formatRuleConditions(rule); + if (conditions) { + ruleText += `\n Conditions: ${conditions}`; + } + + // Format actions + const actions = formatRuleActions(rule); + if (actions) { + ruleText += `\n Actions: ${actions}`; + } + + return ruleText; + }); + + return `Found ${rules.length} inbox rules (sorted by execution order):\n\n${detailedRules.join('\n\n')}\n\nRules are processed in order of their sequence number. You can change rule order using the 'edit-rule-sequence' tool.`; + } else { + // Simple format + const simpleRules = sortedRules.map((rule, index) => { + return `${index + 1}. ${rule.displayName}${rule.isEnabled ? '' : ' (Disabled)'} - Sequence: ${rule.sequence || 'N/A'}`; + }); + + return `Found ${rules.length} inbox rules (sorted by execution order):\n\n${simpleRules.join('\n')}\n\nTip: Use 'list-rules with includeDetails=true' to see more information about each rule.`; + } +} + +/** + * Format rule conditions for display + * @param {object} rule - Rule object + * @returns {string} - Formatted conditions + */ +function formatRuleConditions(rule) { + const conditions = []; + + // From addresses + if (rule.conditions?.fromAddresses?.length > 0) { + const senders = rule.conditions.fromAddresses.map(addr => addr.emailAddress.address).join(', '); + conditions.push(`From: ${senders}`); + } + + // Subject contains + if (rule.conditions?.subjectContains?.length > 0) { + conditions.push(`Subject contains: "${rule.conditions.subjectContains.join(', ')}"`); + } + + // Contains body text + if (rule.conditions?.bodyContains?.length > 0) { + conditions.push(`Body contains: "${rule.conditions.bodyContains.join(', ')}"`); + } + + // Has attachment + if (rule.conditions?.hasAttachment === true) { + conditions.push('Has attachment'); + } + + // Importance + if (rule.conditions?.importance) { + conditions.push(`Importance: ${rule.conditions.importance}`); + } + + return conditions.join('; '); +} + +/** + * Format rule actions for display + * @param {object} rule - Rule object + * @returns {string} - Formatted actions + */ +function formatRuleActions(rule) { + const actions = []; + + // Move to folder + if (rule.actions?.moveToFolder) { + actions.push(`Move to folder: ${rule.actions.moveToFolder}`); + } + + // Copy to folder + if (rule.actions?.copyToFolder) { + actions.push(`Copy to folder: ${rule.actions.copyToFolder}`); + } + + // Mark as read + if (rule.actions?.markAsRead === true) { + actions.push('Mark as read'); + } + + // Mark importance + if (rule.actions?.markImportance) { + actions.push(`Mark importance: ${rule.actions.markImportance}`); + } + + // Forward + if (rule.actions?.forwardTo?.length > 0) { + const recipients = rule.actions.forwardTo.map(r => r.emailAddress.address).join(', '); + actions.push(`Forward to: ${recipients}`); + } + + // Delete + if (rule.actions?.delete === true) { + actions.push('Delete'); + } + + return actions.join('; '); +} + +module.exports = { + handleListRules, + getInboxRules +}; diff --git a/tools/get-email-thread.js b/tools/get-email-thread.js new file mode 100644 index 0000000..637b728 --- /dev/null +++ b/tools/get-email-thread.js @@ -0,0 +1,164 @@ +'use strict'; + +/** + * get-email-thread tool + * Fetches a set of email message IDs, cleans each body, strips quoted history + * from each message, and returns a single clean chronological thread. + * + * This dramatically reduces token usage vs calling read-emails on a chain — + * each message no longer carries the full history of every prior reply. + */ + +const config = require('../config'); +const { callGraphAPI } = require('../utils/graph-api'); +const { ensureAuthenticated } = require('../auth'); +const { buildThread } = require('../utils/threadBuilder'); + +const MAX_MESSAGES = 20; + +/** + * Fetch all messages in a conversation across ALL folders (inbox + sent + etc.) + * using the conversationId filter on the global me/messages endpoint. + */ +async function fetchByConversationId(accessToken, conversationId) { + const allMessages = []; + let url = 'me/messages'; + let params = { + $filter: `conversationId eq '${conversationId}'`, + $select: config.EMAIL_DETAIL_FIELDS, + $top: MAX_MESSAGES, + // NOTE: $orderby intentionally omitted — combining $filter on conversationId + // with $orderby causes a Graph API "InefficientFilter" 400 error. + // buildThread() handles chronological sorting in memory instead. + }; + + // Page through results (unlikely to exceed one page for most threads, but safe) + while (url) { + const page = await callGraphAPI(accessToken, 'GET', url, null, params); + if (page.value) allMessages.push(...page.value); + url = page['@odata.nextLink'] || null; + params = null; // params are embedded in nextLink on subsequent pages + } + + return allMessages; +} + +/** + * Handler for the get-email-thread tool. + * @param {object} args + * @param {string[]} [args.ids] - Explicit message IDs to include + * @param {string} [args.conversationId] - Fetch entire conversation from all folders + * @param {string} [args.subject] - Optional subject label for the thread header + */ +async function handleGetEmailThread(args) { + const { ids, conversationId, subject } = args || {}; + + const hasIds = Array.isArray(ids) && ids.length > 0; + const hasConvId = typeof conversationId === 'string' && conversationId.trim().length > 0; + + if (!hasIds && !hasConvId) { + return { + content: [{ type: 'text', text: 'Provide either an ids array or a conversationId.' }] + }; + } + + if (hasIds && ids.length > MAX_MESSAGES) { + return { + content: [{ type: 'text', text: `Maximum ${MAX_MESSAGES} messages per thread request. ${ids.length} provided.` }] + }; + } + + let accessToken; + try { + accessToken = await ensureAuthenticated(); + } catch { + return { + content: [{ type: 'text', text: "Authentication required. Please use the 'authenticate' tool first." }] + }; + } + + let messages = []; + let failCount = 0; + + if (hasConvId) { + // Auto-fetch entire conversation from all folders (inbox + sent + etc.) + try { + messages = await fetchByConversationId(accessToken, conversationId.trim()); + } catch (err) { + console.error(`[get-email-thread] conversationId fetch failed: ${err.message}`); + return { + content: [{ type: 'text', text: `Failed to fetch conversation: ${err.message}` }] + }; + } + + // If caller also passed explicit IDs, merge in any that weren't in the conversation result + if (hasIds) { + const fetchedIds = new Set(messages.map(m => m.id)); + const extras = await Promise.all( + ids.filter(id => !fetchedIds.has(id)).map(async (id) => { + try { + return await callGraphAPI(accessToken, 'GET', `me/messages/${encodeURIComponent(id)}`, null, { $select: config.EMAIL_DETAIL_FIELDS }); + } catch (err) { + console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`); + failCount++; + return null; + } + }) + ); + messages.push(...extras.filter(Boolean)); + } + } else { + // IDs-only path — fetch concurrently, same as before + const results = await Promise.all(ids.map(async (id) => { + try { + const message = await callGraphAPI(accessToken, 'GET', `me/messages/${encodeURIComponent(id)}`, null, { $select: config.EMAIL_DETAIL_FIELDS }); + return { message, error: null }; + } catch (err) { + console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`); + return { message: null, error: err.message }; + } + })); + messages = results.filter(r => r.message).map(r => r.message); + failCount = results.filter(r => r.error).length; + } + + if (messages.length === 0) { + return { + content: [{ type: 'text', text: 'Could not retrieve any messages. Check IDs/conversationId and authentication.' }] + }; + } + + const thread = buildThread(messages, subject); + const note = failCount > 0 ? `\n\n(Note: ${failCount} message(s) could not be fetched and are excluded.)` : ''; + + return { + content: [{ type: 'text', text: thread + note }] + }; +} + +const threadTool = { + name: 'get-email-thread', + description: 'Fetch a complete email thread and return it as a single clean deduplicated conversation, sorted chronologically. Each message shows only its unique new content — quoted prior replies are stripped, and signatures are deduplicated per sender. Prefer conversationId (from list-emails or search-emails results) to automatically include both inbox AND sent items in the thread. Fall back to ids when you only have specific message IDs without a conversationId.', + inputSchema: { + type: 'object', + properties: { + conversationId: { + type: 'string', + description: 'Conversation ID (from list-emails). Fetches the COMPLETE thread from all folders including Sent Items. Preferred over ids for full thread reconstruction.' + }, + ids: { + type: 'array', + items: { type: 'string' }, + description: 'Array of specific message IDs to include (max 20). Use when you only have individual IDs and no conversationId.', + maxItems: MAX_MESSAGES + }, + subject: { + type: 'string', + description: 'Optional: override the thread subject shown in the header' + } + } + }, + handler: handleGetEmailThread +}; + +module.exports = { threadTool, handleGetEmailThread }; diff --git a/utils/bodyParser.js b/utils/bodyParser.js new file mode 100644 index 0000000..2f677a9 --- /dev/null +++ b/utils/bodyParser.js @@ -0,0 +1,173 @@ +'use strict'; + +/** + * bodyParser.js + * Heuristic cleaner for email body text returned by Microsoft Graph API. + * Strips boilerplate noise without touching actual message content. + * All rules are based on observed real-world patterns from this mailbox. + */ + +// --------------------------------------------------------------------------- +// Pattern library — ordered by application sequence +// --------------------------------------------------------------------------- + +// 1. HTML entities produced by Graph API's text/plain conversion +const HTML_ENTITIES = [ + [/ /gi, ' '], + [/&/gi, '&'], + [/>/gi, '>'], + [/</gi, '<'], + [/"/gi, '"'], + [/'/gi, "'"], +]; + +// 2. External email caution banners — appear at start of body or inline +// Covers variations with/without "This email originated..." sentence +const CAUTION_BANNERS = [ + // Full two-sentence form with bold/marker text + /CAUTION[\s\-–]*EXTERNAL\s+EMAIL\s*:.*?(?:content is safe\.?)/gis, + // Short form + /CAUTION\s*:?\s*This email originated from outside.*?(?:content is safe\.?)/gis, +]; + +// 3. Legal boilerplate blocks — DISCLAIMER and CONFIDENTIALITY NOTICE +// These repeat on every reply in a chain. Match greedy to end of block. +const LEGAL_BLOCKS = [ + // DISCLAIMER block (YMCA, others) + /DISCLAIMER\s*:.*?(?=DISCLAIMER\s*:|CONFIDENTIALITY\s*NOTICE\s*:|$)/gis, + // CONFIDENTIALITY NOTICE block (Prime HHCC, Farber & Lindley, others) + /CONFIDENTIALITY\s*NOTICE\s*:.*?(?=DISCLAIMER\s*:|CONFIDENTIALITY\s*NOTICE\s*:|$)/gis, +]; + +// 4. Signature block delimiters — everything from a recognized sig opener onward +// Only applied when stripping signatures is explicitly requested (see exports). +const SIGNATURE_DELIMITERS = [ + // Standard triple-dash separator + /^---\s*$/m, + // deRenzy signature pattern: name on one line, then IT@/Seton@ email lines + /^Seton Carmichael\s*\n(?:IT@|Seton@)/m, + /^Richard Priest\s*\n/m, +]; + +// --------------------------------------------------------------------------- +// HTML → plain text conversion +// --------------------------------------------------------------------------- + +/** + * Convert HTML email body to plain text, preserving line structure. + * Two-pass: structural tags → newlines first, then strip remaining tags. + * @param {string} html + * @returns {string} + */ +function htmlToText(html) { + let text = html; + // Mark Outlook signature boundaries before conversion. + // Outlook wraps signatures in id="Signature" (or id="x_Signature" on nested messages). + // We replace the opening tag with a sentinel [SIG] so callers can decide whether to + // keep or strip the signature after HTML→text conversion. + text = text.replace(/]*\bid=["'][^"']*[Ss]ignature["'][^>]*>/gi, '\n[SIG]\n'); + // Block-level tags that should become newlines + text = text.replace(//gi, '\n'); + text = text.replace(/<\/(?:div|p|tr|li|blockquote|h[1-6])>/gi, '\n'); + // Strip all remaining tags + text = text.replace(/<[^>]+>/g, ''); + return text; +} + +// --------------------------------------------------------------------------- +// Core cleaner +// --------------------------------------------------------------------------- + +/** + * Clean a single email body string. Handles both HTML and plain text input. + * @param {string} body - Raw body text or HTML from Graph API + * @param {object} [opts] + * @param {boolean} [opts.stripSignature=false] - Also strip trailing signature block + * @returns {string} Cleaned plain text + */ +function cleanBody(body, opts = {}) { + if (!body || typeof body !== 'string') return body || ''; + + let text = body; + + // Step 1: Convert HTML to plain text if needed + if (/]/i.test(text) || /]/i.test(text) || /]/i.test(text)) { + text = htmlToText(text); + } + + // Step 2: Normalize HTML entities (may remain after tag stripping) + for (const [pattern, replacement] of HTML_ENTITIES) { + text = text.replace(pattern, replacement); + } + + // Step 2: Strip external caution banners + for (const pattern of CAUTION_BANNERS) { + text = text.replace(pattern, ''); + } + + // Step 3: Strip legal boilerplate blocks + for (const pattern of LEGAL_BLOCKS) { + text = text.replace(pattern, ''); + } + + // Step 4: Handle [SIG] sentinel (injected by htmlToText for id="Signature" divs) + if (opts.stripSignature) { + // Remove from [SIG] marker onward + text = text.replace(/\n?\[SIG\][\s\S]*/g, ''); + } else { + // Keep signature content, just remove the marker itself + text = text.replace(/\[SIG\]\n?/g, ''); + } + + // Step 4b: Text-based signature delimiters (fallback for non-HTML emails) + if (opts.stripSignature) { + for (const delimiter of SIGNATURE_DELIMITERS) { + const match = text.search(delimiter); + if (match !== -1) { + text = text.slice(0, match); + break; + } + } + } + + // Step 5: Collapse runs of 3+ blank lines down to 2, trim edges + text = text.replace(/\n{3,}/g, '\n\n'); + text = text.trim(); + + return text; +} + +/** + * Clean the body field of a Graph API email message object in-place. + * Returns the same object with body.content cleaned. + * Also cleans bodyPreview if present. + * @param {object} message - Graph API message object + * @param {object} [opts] - Same options as cleanBody + * @returns {object} The same message object, mutated + */ +function cleanMessage(message, opts = {}) { + if (!message) return message; + + if (message.body && message.body.content) { + message.body.content = cleanBody(message.body.content, opts); + } + // bodyPreview is a short excerpt — just do entity decode and trim + if (message.bodyPreview) { + message.bodyPreview = cleanBody(message.bodyPreview, { stripSignature: false }); + } + + return message; +} + +/** + * Clean an array of message objects. + * @param {object[]} messages + * @param {object} [opts] + * @returns {object[]} + */ +function cleanMessages(messages, opts = {}) { + if (!Array.isArray(messages)) return messages; + return messages.map(m => cleanMessage(m, opts)); +} + +module.exports = { cleanBody, cleanMessage, cleanMessages }; diff --git a/utils/graph-api.js b/utils/graph-api.js new file mode 100644 index 0000000..f69ecab --- /dev/null +++ b/utils/graph-api.js @@ -0,0 +1,119 @@ +/** + * Microsoft Graph API helper functions + */ +const https = require('https'); +const config = require('../config'); +const mockData = require('./mock-data'); + +/** + * Makes a request to the Microsoft Graph API + * @param {string} accessToken - The access token for authentication + * @param {string} method - HTTP method (GET, POST, etc.) + * @param {string} path - API endpoint path + * @param {object} data - Data to send for POST/PUT requests + * @param {object} queryParams - Query parameters + * @returns {Promise} - The API response + */ +async function callGraphAPI(accessToken, method, path, data = null, queryParams = {}) { + // For test tokens, we'll simulate the API call + if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) { + console.error(`TEST MODE: Simulating ${method} ${path} API call`); + return mockData.simulateGraphAPIResponse(method, path, data, queryParams); + } + + try { + console.error(`Making real API call: ${method} ${path}`); + + // Encode path segments properly + const encodedPath = path.split('/') + .map(segment => encodeURIComponent(segment)) + .join('/'); + + // Build query string from parameters with special handling for OData filters + let queryString = ''; + if (Object.keys(queryParams).length > 0) { + // Handle $filter parameter specially to ensure proper URI encoding + const filter = queryParams.$filter; + if (filter) { + delete queryParams.$filter; // Remove from regular params + } + + // Build query string with proper encoding for regular params + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(queryParams)) { + params.append(key, value); + } + + queryString = params.toString(); + + // Add filter parameter separately with proper encoding + if (filter) { + if (queryString) { + queryString += `&$filter=${encodeURIComponent(filter)}`; + } else { + queryString = `$filter=${encodeURIComponent(filter)}`; + } + } + + if (queryString) { + queryString = '?' + queryString; + } + + console.error(`Query string: ${queryString}`); + } + + const url = `${config.GRAPH_API_ENDPOINT}${encodedPath}${queryString}`; + console.error(`Full URL: ${url}`); + + return new Promise((resolve, reject) => { + const options = { + method: method, + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + } + }; + + const req = https.request(url, options, (res) => { + let responseData = ''; + + res.on('data', (chunk) => { + responseData += chunk; + }); + + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + const jsonResponse = JSON.parse(responseData); + resolve(jsonResponse); + } catch (error) { + reject(new Error(`Error parsing API response: ${error.message}`)); + } + } else if (res.statusCode === 401) { + // Token expired or invalid + reject(new Error('UNAUTHORIZED')); + } else { + reject(new Error(`API call failed with status ${res.statusCode}: ${responseData}`)); + } + }); + }); + + req.on('error', (error) => { + reject(new Error(`Network error during API call: ${error.message}`)); + }); + + if (data && (method === 'POST' || method === 'PATCH' || method === 'PUT')) { + req.write(JSON.stringify(data)); + } + + req.end(); + }); + } catch (error) { + console.error('Error calling Graph API:', error); + throw error; + } +} + +module.exports = { + callGraphAPI +}; diff --git a/utils/mock-data.js b/utils/mock-data.js new file mode 100644 index 0000000..9b2ab81 --- /dev/null +++ b/utils/mock-data.js @@ -0,0 +1,145 @@ +/** + * Mock data functions for test mode + */ + +/** + * Simulates Microsoft Graph API responses for testing + * @param {string} method - HTTP method + * @param {string} path - API path + * @param {object} data - Request data + * @param {object} queryParams - Query parameters + * @returns {object} - Simulated API response + */ +function simulateGraphAPIResponse(method, path, data, queryParams) { + console.error(`Simulating response for: ${method} ${path}`); + + if (method === 'GET') { + if (path.includes('messages') && !path.includes('sendMail')) { + // Simulate a successful email list/search response + if (path.includes('/messages/')) { + // Single email response + return { + id: "simulated-email-id", + subject: "Simulated Email Subject", + from: { + emailAddress: { + name: "Simulated Sender", + address: "sender@example.com" + } + }, + toRecipients: [{ + emailAddress: { + name: "Recipient Name", + address: "recipient@example.com" + } + }], + ccRecipients: [], + bccRecipients: [], + receivedDateTime: new Date().toISOString(), + bodyPreview: "This is a simulated email preview...", + body: { + contentType: "text", + content: "This is the full content of the simulated email. Since we can't connect to the real Microsoft Graph API, we're returning this placeholder content instead." + }, + hasAttachments: false, + importance: "normal", + isRead: false, + internetMessageHeaders: [] + }; + } else { + // Email list response + return { + value: [ + { + id: "simulated-email-1", + subject: "Important Meeting Tomorrow", + from: { + emailAddress: { + name: "John Doe", + address: "john@example.com" + } + }, + toRecipients: [{ + emailAddress: { + name: "You", + address: "you@example.com" + } + }], + ccRecipients: [], + receivedDateTime: new Date().toISOString(), + bodyPreview: "Let's discuss the project status...", + hasAttachments: false, + importance: "high", + isRead: false + }, + { + id: "simulated-email-2", + subject: "Weekly Report", + from: { + emailAddress: { + name: "Jane Smith", + address: "jane@example.com" + } + }, + toRecipients: [{ + emailAddress: { + name: "You", + address: "you@example.com" + } + }], + ccRecipients: [], + receivedDateTime: new Date(Date.now() - 86400000).toISOString(), // Yesterday + bodyPreview: "Please find attached the weekly report...", + hasAttachments: true, + importance: "normal", + isRead: true + }, + { + id: "simulated-email-3", + subject: "Question about the project", + from: { + emailAddress: { + name: "Bob Johnson", + address: "bob@example.com" + } + }, + toRecipients: [{ + emailAddress: { + name: "You", + address: "you@example.com" + } + }], + ccRecipients: [], + receivedDateTime: new Date(Date.now() - 172800000).toISOString(), // 2 days ago + bodyPreview: "I had a question about the timeline...", + hasAttachments: false, + importance: "normal", + isRead: false + } + ] + }; + } + } else if (path.includes('mailFolders')) { + // Simulate a mail folders response + return { + value: [ + { id: "inbox", displayName: "Inbox" }, + { id: "drafts", displayName: "Drafts" }, + { id: "sentItems", displayName: "Sent Items" }, + { id: "deleteditems", displayName: "Deleted Items" } + ] + }; + } + } else if (method === 'POST' && path.includes('sendMail')) { + // Simulate a successful email send + return {}; + } + + // If we get here, we don't have a simulation for this endpoint + console.error(`No simulation available for: ${method} ${path}`); + return {}; +} + +module.exports = { + simulateGraphAPIResponse +}; diff --git a/utils/odata-helpers.js b/utils/odata-helpers.js new file mode 100644 index 0000000..0a9122f --- /dev/null +++ b/utils/odata-helpers.js @@ -0,0 +1,221 @@ +/** + * OData helper functions for Microsoft Graph API + */ + +/** + * Escapes a string for use in OData queries + * @param {string} str - The string to escape + * @returns {string} - The escaped string + */ +function escapeODataString(str) { + if (!str) return str; + + // Replace single quotes with double single quotes (OData escaping) + // And remove any special characters that could cause OData syntax errors + str = str.replace(/'/g, "''"); + + // Escape other potentially problematic characters + str = str.replace(/[\(\)\{\}\[\]\:\;\,\/\?\&\=\+\*\%\$\#\@\!\^]/g, ''); + + console.error(`Escaped OData string: '${str}'`); + return str; +} + +/** + * Builds an OData filter from filter conditions + * @param {Array} conditions - Array of filter conditions + * @returns {string} - Combined OData filter expression + */ +function buildODataFilter(conditions) { + if (!conditions || conditions.length === 0) { + return ''; + } + + return conditions.join(' and '); +} + +/** + * Gets start of day for a given date + * @param {Date} date - The date + * @returns {Date} - Start of day + */ +function startOfDay(date) { + const start = new Date(date); + start.setHours(0, 0, 0, 0); + return start; +} + +/** + * Gets end of day for a given date + * @param {Date} date - The date + * @returns {Date} - End of day + */ +function endOfDay(date) { + const end = new Date(date); + end.setHours(23, 59, 59, 999); + return end; +} + +/** + * Parses date input (ISO string or relative date) + * @param {string} dateInput - Date string + * @returns {Date} - Parsed date + */ +function parseDate(dateInput) { + if (!dateInput) return null; + + const now = new Date(); + const today = new Date(now); + + // Handle relative dates + const relativeMap = { + 'today': today, + 'yesterday': new Date(now.getTime() - 24*60*60*1000), + 'tomorrow': new Date(now.getTime() + 24*60*60*1000), + 'last7days': new Date(now.getTime() - 7*24*60*60*1000), + 'last30days': new Date(now.getTime() - 30*24*60*60*1000), + 'last90days': new Date(now.getTime() - 90*24*60*60*1000) + }; + + if (relativeMap[dateInput.toLowerCase()]) { + return relativeMap[dateInput.toLowerCase()]; + } + + // Handle ISO dates + const parsed = new Date(dateInput); + if (isNaN(parsed.getTime())) { + throw new Error(`Invalid date format: ${dateInput}`); + } + + return parsed; +} + +/** + * Processes predefined date ranges + * @param {string} dateRange - Predefined range + * @returns {Object} - Object with from and to dates + */ +function processDateRange(dateRange) { + if (!dateRange) return null; + + const now = new Date(); + const today = new Date(now); + + switch (dateRange.toLowerCase()) { + case 'today': + return { + from: startOfDay(today), + to: endOfDay(today) + }; + + case 'yesterday': { + const yesterday = new Date(now.getTime() - 24*60*60*1000); + return { + from: startOfDay(yesterday), + to: endOfDay(yesterday) + }; + } + + case 'last7days': + return { + from: new Date(now.getTime() - 7*24*60*60*1000), + to: now + }; + + case 'last30days': + return { + from: new Date(now.getTime() - 30*24*60*60*1000), + to: now + }; + + case 'thisweek': { + const startOfWeek = new Date(today); + startOfWeek.setDate(today.getDate() - today.getDay()); + return { + from: startOfDay(startOfWeek), + to: endOfDay(today) + }; + } + + case 'lastweek': { + const startOfLastWeek = new Date(today); + startOfLastWeek.setDate(today.getDate() - today.getDay() - 7); + const endOfLastWeek = new Date(startOfLastWeek); + endOfLastWeek.setDate(startOfLastWeek.getDate() + 6); + return { + from: startOfDay(startOfLastWeek), + to: endOfDay(endOfLastWeek) + }; + } + + case 'thismonth': { + const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + return { + from: startOfDay(startOfMonth), + to: endOfDay(today) + }; + } + + case 'lastmonth': { + const startOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1); + const endOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0); + return { + from: startOfDay(startOfLastMonth), + to: endOfDay(endOfLastMonth) + }; + } + + default: + throw new Error(`Unknown date range: ${dateRange}`); + } +} + +/** + * Builds date filter conditions for OData queries + * @param {string} dateFrom - Start date + * @param {string} dateTo - End date + * @param {string} dateRange - Predefined range + * @returns {Array} - Array of filter conditions + */ +function buildDateFilter(dateFrom, dateTo, dateRange) { + const conditions = []; + + try { + if (dateRange) { + const range = processDateRange(dateRange); + if (range) { + conditions.push(`receivedDateTime ge ${range.from.toISOString()}`); + conditions.push(`receivedDateTime le ${range.to.toISOString()}`); + } + } else { + if (dateFrom) { + const fromDate = parseDate(dateFrom); + conditions.push(`receivedDateTime ge ${fromDate.toISOString()}`); + } + if (dateTo) { + const toDate = parseDate(dateTo); + // If only date provided (no time), set to end of day + if (dateTo.length === 10) { // YYYY-MM-DD format + conditions.push(`receivedDateTime le ${endOfDay(toDate).toISOString()}`); + } else { + conditions.push(`receivedDateTime le ${toDate.toISOString()}`); + } + } + } + } catch (error) { + console.error(`Date filter error: ${error.message}`); + // Return empty conditions on error to avoid breaking the query + } + + return conditions; +} + +module.exports = { + escapeODataString, + buildODataFilter, + parseDate, + processDateRange, + buildDateFilter, + startOfDay, + endOfDay +}; diff --git a/utils/threadBuilder.js b/utils/threadBuilder.js new file mode 100644 index 0000000..1002c5c --- /dev/null +++ b/utils/threadBuilder.js @@ -0,0 +1,160 @@ +'use strict'; + +const { cleanBody } = require('./bodyParser'); + +/** + * threadBuilder.js + * Reconstructs a clean, deduplicated email thread from a set of Graph API + * message objects. Each message is stripped down to only its unique new + * content — quoted prior messages are removed. + */ + +// --------------------------------------------------------------------------- +// Quote-stripping patterns +// Ordered from most specific to most general. +// --------------------------------------------------------------------------- + +const QUOTE_BOUNDARIES = [ + // Outlook-style attribution: "From: Name \nSent: ..." + /^From\s*:\s*.+\n(?:Sent|Date)\s*:/im, + // Outlook-style with leading whitespace/non-breaking spaces + /^\s*From\s*:\s*.+\n\s*(?:Sent|Date)\s*:/im, + // Gmail/web client: "On Mon, Mar 13, 2026 at 8:32 AM, Name wrote:" + /^On\s+.{5,80}wrote\s*:\s*$/im, + // Simple "wrote:" attribution line + /^.{0,100}<.+@.+>\s+wrote\s*:/im, + // Forwarded message header block + /^-{3,}\s*(?:Forwarded|Original)\s+[Mm]essage\s*-{3,}/im, + // Lines starting with > (standard quote marker) + /^>+\s/m, +]; + +// --------------------------------------------------------------------------- +// Core functions +// --------------------------------------------------------------------------- + +/** + * Extract only the new/unique content from a single message body, + * stripping all quoted prior messages. + * @param {string} body - Cleaned body text + * @returns {string} Unique message content only + */ +function extractUniqueContent(body) { + if (!body) return ''; + + let earliestBoundary = body.length; + + for (const pattern of QUOTE_BOUNDARIES) { + const match = body.search(pattern); + if (match !== -1 && match < earliestBoundary) { + earliestBoundary = match; + } + } + + const unique = body.slice(0, earliestBoundary).trim(); + return unique; +} + +/** + * Format a single message as a clean thread entry. + * @param {object} msg - Graph API message object (already body-cleaned) + * @param {number} index - 1-based position in thread + * @returns {string} + */ +function formatThreadEntry(msg, index) { + const from = msg.from?.emailAddress + ? `${msg.from.emailAddress.name || ''} <${msg.from.emailAddress.address}>`.trim() + : 'Unknown'; + + const to = (msg.toRecipients || []) + .map(r => r.emailAddress?.name || r.emailAddress?.address || '') + .filter(Boolean) + .join(', ') || 'Unknown'; + + const cc = (msg.ccRecipients || []) + .map(r => r.emailAddress?.name || r.emailAddress?.address || '') + .filter(Boolean) + .join(', '); + + const date = msg.receivedDateTime + ? new Date(msg.receivedDateTime).toLocaleString('en-US', { + month: 'numeric', day: 'numeric', year: 'numeric', + hour: 'numeric', minute: '2-digit', hour12: true + }) + : 'Unknown date'; + + const bodyText = msg.body?.content || msg.bodyPreview || ''; + const unique = extractUniqueContent(bodyText); + + const lines = [ + `[${index}] ${date}`, + `From: ${from}`, + `To: ${to}`, + ]; + if (cc) lines.push(`CC: ${cc}`); + if (msg.hasAttachments) lines.push('Attachments: Yes'); + lines.push(''); + lines.push(unique || '(no unique content)'); + + return lines.join('\n'); +} + +/** + * Build a clean deduplicated thread from an array of Graph API message objects. + * Messages are sorted chronologically and each is stripped to unique content only. + * + * @param {object[]} messages - Array of Graph API message objects with body.content populated + * @param {string} [subject] - Thread subject (inferred from first message if omitted) + * @returns {string} Formatted thread as a single string + */ +function buildThread(messages, subject) { + if (!Array.isArray(messages) || messages.length === 0) { + return '(no messages)'; + } + + // Sort chronologically + const sorted = [...messages].sort((a, b) => { + const ta = a.receivedDateTime ? new Date(a.receivedDateTime).getTime() : 0; + const tb = b.receivedDateTime ? new Date(b.receivedDateTime).getTime() : 0; + return ta - tb; + }); + + // Infer subject if not provided + const threadSubject = subject + || sorted[0]?.subject + || '(no subject)'; + + // Strip Re:/Fw: prefix for display + const displaySubject = threadSubject.replace(/^(Re|Fwd?)\s*:\s*/i, '').trim(); + + const header = [ + `Thread: ${displaySubject}`, + `Messages: ${sorted.length}`, + '='.repeat(60), + '', + ].join('\n'); + + // Track which senders have already had their signature included. + // First message from each sender keeps the signature; repeats get it stripped. + const seenSenders = new Set(); + const entries = sorted + .map((msg, i) => { + const senderEmail = msg.from?.emailAddress?.address?.toLowerCase() || ''; + const isRepeat = seenSenders.has(senderEmail); + if (senderEmail) seenSenders.add(senderEmail); + + const cleanedMsg = { ...msg }; + if (cleanedMsg.body?.content) { + cleanedMsg.body = { + ...cleanedMsg.body, + content: cleanBody(cleanedMsg.body.content, { stripSignature: isRepeat }), + }; + } + return formatThreadEntry(cleanedMsg, i + 1); + }) + .join('\n\n' + '-'.repeat(40) + '\n\n'); + + return header + entries; +} + +module.exports = { buildThread, extractUniqueContent };