outlook-reader-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +74 -0
- package/dist/auth.js +219 -0
- package/dist/files.js +32 -0
- package/dist/mail.js +120 -0
- package/dist/mcp-server.js +31 -0
- package/dist/tools/authenticate/definition.js +31 -0
- package/dist/tools/authenticate/index.js +10 -0
- package/dist/tools/authenticate/key.js +4 -0
- package/dist/tools/authenticate/schema.js +5 -0
- package/dist/tools/download-attachment/definition.js +36 -0
- package/dist/tools/download-attachment/index.js +10 -0
- package/dist/tools/download-attachment/key.js +4 -0
- package/dist/tools/download-attachment/schema.js +14 -0
- package/dist/tools/download-attachments/definition.js +51 -0
- package/dist/tools/download-attachments/index.js +10 -0
- package/dist/tools/download-attachments/key.js +4 -0
- package/dist/tools/download-attachments/schema.js +20 -0
- package/dist/tools/get-email/definition.js +33 -0
- package/dist/tools/get-email/index.js +10 -0
- package/dist/tools/get-email/key.js +4 -0
- package/dist/tools/get-email/schema.js +9 -0
- package/dist/tools/get-emails/definition.js +31 -0
- package/dist/tools/get-emails/index.js +10 -0
- package/dist/tools/get-emails/key.js +4 -0
- package/dist/tools/get-emails/schema.js +11 -0
- package/dist/tools/index.js +25 -0
- package/dist/tools/list-attachments/definition.js +24 -0
- package/dist/tools/list-attachments/index.js +10 -0
- package/dist/tools/list-attachments/key.js +4 -0
- package/dist/tools/list-attachments/schema.js +9 -0
- package/dist/tools/list-emails/definition.js +33 -0
- package/dist/tools/list-emails/index.js +10 -0
- package/dist/tools/list-emails/key.js +4 -0
- package/dist/tools/list-emails/schema.js +34 -0
- package/dist/tools/list-folders/definition.js +15 -0
- package/dist/tools/list-folders/index.js +10 -0
- package/dist/tools/list-folders/key.js +4 -0
- package/dist/tools/list-folders/schema.js +5 -0
- package/dist/tools/search-emails/definition.js +24 -0
- package/dist/tools/search-emails/index.js +10 -0
- package/dist/tools/search-emails/key.js +4 -0
- package/dist/tools/search-emails/schema.js +17 -0
- package/dist/tools/sign-out/definition.js +13 -0
- package/dist/tools/sign-out/index.js +10 -0
- package/dist/tools/sign-out/key.js +4 -0
- package/dist/tools/sign-out/schema.js +5 -0
- package/dist/tools/wrap.js +45 -0
- package/package.json +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# outlook-reader-mcp
|
|
2
|
+
|
|
3
|
+
MCP (Model Context Protocol) server for reading Outlook / Microsoft 365 mail through Microsoft Graph. Read-only by design: list, search, and fetch emails — single or batched — and download attachments safely to disk.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
| Tool | Description |
|
|
8
|
+
| ---------------------- | ----------------------------------------------------------------------------------- |
|
|
9
|
+
| `authenticate` | Sign in with a device code shown directly in the chat; reports current account |
|
|
10
|
+
| `sign_out` | Remove the cached session from this machine; cancels any pending sign-in |
|
|
11
|
+
| `list_emails` | List emails from any folder with OData filters, ordering, and paging (`top`/`skip`) |
|
|
12
|
+
| `search_emails` | Keyword search across subject, body, sender, and recipients |
|
|
13
|
+
| `get_email` | Full content of one email (plain-text body) |
|
|
14
|
+
| `get_emails` | Full content of up to 50 emails in one batched Graph request |
|
|
15
|
+
| `list_folders` | Mail folders with unread/total counts |
|
|
16
|
+
| `list_attachments` | Attachment IDs, names, sizes, and types for an email |
|
|
17
|
+
| `download_attachment` | Save one attachment to disk |
|
|
18
|
+
| `download_attachments` | Save several (or all) attachments of an email in one call |
|
|
19
|
+
|
|
20
|
+
## Setup
|
|
21
|
+
|
|
22
|
+
### 1. Create an Azure app registration
|
|
23
|
+
|
|
24
|
+
1. Go to [Azure Portal → App registrations](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) → **New registration**.
|
|
25
|
+
2. Supported account types: pick what fits — "Personal Microsoft accounts only" for outlook.com/hotmail mailboxes, or an org option for Microsoft 365.
|
|
26
|
+
3. No redirect URI needed. After creating, under **Authentication** enable **Allow public client flows**.
|
|
27
|
+
4. Under **API permissions** add delegated Microsoft Graph permissions: `Mail.Read`, `User.Read`.
|
|
28
|
+
5. Copy the **Application (client) ID**.
|
|
29
|
+
|
|
30
|
+
### 2. Configure your MCP client
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"outlook-reader": {
|
|
36
|
+
"command": "npx",
|
|
37
|
+
"args": ["-y", "outlook-reader-mcp"],
|
|
38
|
+
"env": {
|
|
39
|
+
"CLIENT_ID": "your-application-client-id",
|
|
40
|
+
"TENANT_ID": "consumers"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`TENANT_ID`: `consumers` for personal accounts, `organizations` or your tenant GUID for work accounts, `common` (default) for both.
|
|
48
|
+
|
|
49
|
+
Optional: `OUTLOOK_ACCOUNT=you@example.com` pins which cached account to use if several have signed in.
|
|
50
|
+
|
|
51
|
+
### 3. First run
|
|
52
|
+
|
|
53
|
+
Ask your assistant to run the `authenticate` tool (or just ask it to read your mail — any tool that needs auth will tell it to). The chat shows a URL and a one-time code: open the URL, enter the code, sign in. Sessions are cached, so this only happens once per machine.
|
|
54
|
+
|
|
55
|
+
## Token cache & security
|
|
56
|
+
|
|
57
|
+
- Tokens are cached per user in the OS application-data directory (`%LOCALAPPDATA%\outlook-reader-mcp` on Windows, `~/Library/Application Support/outlook-reader-mcp` on macOS, `$XDG_CONFIG_HOME/outlook-reader-mcp` on Linux).
|
|
58
|
+
- The cache is encrypted at rest with the OS credential store (DPAPI / Keychain / libsecret). If no store is available it falls back to a permission-restricted plaintext file and warns on stderr.
|
|
59
|
+
- Scopes are read-only (`Mail.Read`); this server cannot send, modify, or delete mail.
|
|
60
|
+
- Attachment filenames are sanitized (path traversal, reserved characters) before writing to disk, and existing files are never overwritten.
|
|
61
|
+
- To revoke access: remove the app under [account.live.com/consent/Manage](https://account.live.com/consent/Manage) (personal) or have your admin revoke sessions, then delete the cache directory.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm install
|
|
67
|
+
npm run mcp # run from source (tsx)
|
|
68
|
+
npm run build # compile to dist/
|
|
69
|
+
npm run format # prettier
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
GPL-3.0
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.AuthRequiredError = void 0;
|
|
7
|
+
exports.getConfig = getConfig;
|
|
8
|
+
exports.startDeviceCodeAuth = startDeviceCodeAuth;
|
|
9
|
+
exports.clearAuth = clearAuth;
|
|
10
|
+
exports.getSignedInAccount = getSignedInAccount;
|
|
11
|
+
exports.getAccessToken = getAccessToken;
|
|
12
|
+
const fs_1 = __importDefault(require("fs"));
|
|
13
|
+
const os_1 = require("os");
|
|
14
|
+
const path_1 = __importDefault(require("path"));
|
|
15
|
+
const msal_node_1 = require("@azure/msal-node");
|
|
16
|
+
const msal_node_extensions_1 = require("@azure/msal-node-extensions");
|
|
17
|
+
const SCOPES = ['Mail.Read', 'User.Read'];
|
|
18
|
+
const APP_DIR_NAME = 'outlook-reader-mcp';
|
|
19
|
+
function getConfig() {
|
|
20
|
+
const clientId = process.env.CLIENT_ID;
|
|
21
|
+
if (!clientId) {
|
|
22
|
+
throw new Error('CLIENT_ID environment variable is required. Create an Azure app registration ' +
|
|
23
|
+
'(public client, device code flow, Mail.Read + User.Read delegated permissions) ' +
|
|
24
|
+
'and set CLIENT_ID to its application ID.');
|
|
25
|
+
}
|
|
26
|
+
return { clientId, tenantId: process.env.TENANT_ID ?? 'common' };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Per-user OS data directory. The token cache must never live inside the
|
|
30
|
+
* package or a project folder: it would be world-shared on global installs
|
|
31
|
+
* and one `git add -f` away from being published.
|
|
32
|
+
*/
|
|
33
|
+
function cacheDir() {
|
|
34
|
+
if (process.platform === 'win32') {
|
|
35
|
+
return path_1.default.join(process.env.LOCALAPPDATA ?? path_1.default.join((0, os_1.homedir)(), 'AppData', 'Local'), APP_DIR_NAME);
|
|
36
|
+
}
|
|
37
|
+
if (process.platform === 'darwin') {
|
|
38
|
+
return path_1.default.join((0, os_1.homedir)(), 'Library', 'Application Support', APP_DIR_NAME);
|
|
39
|
+
}
|
|
40
|
+
return path_1.default.join(process.env.XDG_CONFIG_HOME ?? path_1.default.join((0, os_1.homedir)(), '.config'), APP_DIR_NAME);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Encrypted-at-rest persistence: DPAPI on Windows, Keychain on macOS,
|
|
44
|
+
* libsecret on Linux. Falls back to a plaintext file (chmod 600 where
|
|
45
|
+
* supported) only if the platform store is unavailable.
|
|
46
|
+
*/
|
|
47
|
+
async function createPersistence(cachePath) {
|
|
48
|
+
try {
|
|
49
|
+
return await msal_node_extensions_1.PersistenceCreator.createPersistence({
|
|
50
|
+
cachePath,
|
|
51
|
+
dataProtectionScope: msal_node_extensions_1.DataProtectionScope.CurrentUser,
|
|
52
|
+
serviceName: APP_DIR_NAME,
|
|
53
|
+
accountName: 'msal-token-cache',
|
|
54
|
+
usePlaintextFileOnLinux: false,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
console.error(`Warning: OS-level token encryption unavailable (${err instanceof Error ? err.message : err}). ` +
|
|
59
|
+
`Falling back to a plaintext token cache at ${cachePath} — protect this file.`);
|
|
60
|
+
const persistence = await msal_node_extensions_1.FilePersistence.create(cachePath);
|
|
61
|
+
if (process.platform !== 'win32') {
|
|
62
|
+
fs_1.default.chmodSync(cachePath, 0o600);
|
|
63
|
+
}
|
|
64
|
+
return persistence;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
let msalAppPromise = null;
|
|
68
|
+
let cachedToken = null;
|
|
69
|
+
function buildMsalApp() {
|
|
70
|
+
if (msalAppPromise)
|
|
71
|
+
return msalAppPromise;
|
|
72
|
+
msalAppPromise = (async () => {
|
|
73
|
+
const { clientId, tenantId } = getConfig();
|
|
74
|
+
const dir = cacheDir();
|
|
75
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
76
|
+
const persistence = await createPersistence(path_1.default.join(dir, 'token_cache.bin'));
|
|
77
|
+
return new msal_node_1.PublicClientApplication({
|
|
78
|
+
auth: {
|
|
79
|
+
clientId,
|
|
80
|
+
authority: `https://login.microsoftonline.com/${tenantId}`,
|
|
81
|
+
},
|
|
82
|
+
cache: { cachePlugin: new msal_node_extensions_1.PersistenceCachePlugin(persistence) },
|
|
83
|
+
});
|
|
84
|
+
})();
|
|
85
|
+
return msalAppPromise;
|
|
86
|
+
}
|
|
87
|
+
function pickAccount(accounts) {
|
|
88
|
+
const hint = process.env.OUTLOOK_ACCOUNT?.toLowerCase();
|
|
89
|
+
const account = hint
|
|
90
|
+
? (accounts.find((a) => a.username.toLowerCase() === hint) ?? accounts[0])
|
|
91
|
+
: accounts[0];
|
|
92
|
+
if (accounts.length > 1 ||
|
|
93
|
+
(hint && account.username.toLowerCase() !== hint)) {
|
|
94
|
+
console.error(`Using cached account: ${account.username}`);
|
|
95
|
+
}
|
|
96
|
+
return account;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Thrown when a request needs the user to sign in. Tool handlers surface the
|
|
100
|
+
* message as tool output so the model can tell the user exactly what to do —
|
|
101
|
+
* auth must never silently block inside a tool call.
|
|
102
|
+
*/
|
|
103
|
+
class AuthRequiredError extends Error {
|
|
104
|
+
}
|
|
105
|
+
exports.AuthRequiredError = AuthRequiredError;
|
|
106
|
+
let pendingAuth = null;
|
|
107
|
+
// Held so sign_out can abort an in-flight device code flow: MSAL checks
|
|
108
|
+
// request.cancel between polls.
|
|
109
|
+
let pendingRequest = null;
|
|
110
|
+
function rememberToken(result) {
|
|
111
|
+
cachedToken = {
|
|
112
|
+
token: result.accessToken,
|
|
113
|
+
expiresOn: result.expiresOn?.getTime() ?? Date.now() + 5 * 60_000,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Kicks off the device code flow and resolves as soon as the verification
|
|
118
|
+
* URL + code are available, while MSAL keeps polling in the background.
|
|
119
|
+
* Calling again while a code is still valid returns the same code.
|
|
120
|
+
*/
|
|
121
|
+
async function startDeviceCodeAuth() {
|
|
122
|
+
if (pendingAuth && Date.now() < pendingAuth.expiresAt) {
|
|
123
|
+
return pendingAuth.verification;
|
|
124
|
+
}
|
|
125
|
+
const app = await buildMsalApp();
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const request = {
|
|
128
|
+
scopes: SCOPES,
|
|
129
|
+
deviceCodeCallback: (response) => {
|
|
130
|
+
pendingAuth = {
|
|
131
|
+
verification: response.message,
|
|
132
|
+
expiresAt: Date.now() + response.expiresIn * 1000,
|
|
133
|
+
};
|
|
134
|
+
// Also mirror to the server log.
|
|
135
|
+
console.error(response.message);
|
|
136
|
+
resolve(response.message);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
pendingRequest = request;
|
|
140
|
+
app
|
|
141
|
+
.acquireTokenByDeviceCode(request)
|
|
142
|
+
.then((result) => {
|
|
143
|
+
if (result) {
|
|
144
|
+
rememberToken(result);
|
|
145
|
+
console.error(`Signed in as ${result.account?.username ?? 'unknown account'}.`);
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
.catch((err) => {
|
|
149
|
+
// Reaches the tool only if the flow failed before the code was
|
|
150
|
+
// issued; after resolve() this is a no-op and the error is logged.
|
|
151
|
+
reject(err);
|
|
152
|
+
console.error(`Device code sign-in did not complete: ${err instanceof Error ? err.message : err}`);
|
|
153
|
+
})
|
|
154
|
+
.finally(() => {
|
|
155
|
+
pendingAuth = null;
|
|
156
|
+
pendingRequest = null;
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Signs out: cancels any in-flight device code flow, drops the in-memory
|
|
162
|
+
* token, and removes all accounts from the persistent cache. Returns the
|
|
163
|
+
* usernames that were removed.
|
|
164
|
+
*/
|
|
165
|
+
async function clearAuth() {
|
|
166
|
+
cachedToken = null;
|
|
167
|
+
if (pendingRequest) {
|
|
168
|
+
pendingRequest.cancel = true;
|
|
169
|
+
pendingRequest = null;
|
|
170
|
+
}
|
|
171
|
+
pendingAuth = null;
|
|
172
|
+
const app = await buildMsalApp();
|
|
173
|
+
const cache = app.getTokenCache();
|
|
174
|
+
const accounts = await cache.getAllAccounts();
|
|
175
|
+
for (const account of accounts) {
|
|
176
|
+
await cache.removeAccount(account);
|
|
177
|
+
}
|
|
178
|
+
return accounts.map((a) => a.username);
|
|
179
|
+
}
|
|
180
|
+
/** Username of the signed-in account, or null when not authenticated. */
|
|
181
|
+
async function getSignedInAccount() {
|
|
182
|
+
const app = await buildMsalApp();
|
|
183
|
+
const accounts = await app.getTokenCache().getAllAccounts();
|
|
184
|
+
return accounts.length > 0 ? pickAccount(accounts).username : null;
|
|
185
|
+
}
|
|
186
|
+
async function getAccessToken() {
|
|
187
|
+
// Reuse the in-memory token until shortly before expiry to avoid
|
|
188
|
+
// hitting the MSAL cache (and the OS credential store) on every request.
|
|
189
|
+
if (cachedToken && Date.now() < cachedToken.expiresOn - 60_000) {
|
|
190
|
+
return cachedToken.token;
|
|
191
|
+
}
|
|
192
|
+
const app = await buildMsalApp();
|
|
193
|
+
const accounts = await app.getTokenCache().getAllAccounts();
|
|
194
|
+
if (accounts.length === 0) {
|
|
195
|
+
if (pendingAuth && Date.now() < pendingAuth.expiresAt) {
|
|
196
|
+
throw new AuthRequiredError(`Sign-in is still pending. ${pendingAuth.verification} Retry this request after signing in.`);
|
|
197
|
+
}
|
|
198
|
+
throw new AuthRequiredError('Not signed in to a Microsoft account. Run the authenticate tool first, ' +
|
|
199
|
+
'then retry this request.');
|
|
200
|
+
}
|
|
201
|
+
const account = pickAccount(accounts);
|
|
202
|
+
let result;
|
|
203
|
+
try {
|
|
204
|
+
result = await app.acquireTokenSilent({ account, scopes: SCOPES });
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
if (!(err instanceof msal_node_1.InteractionRequiredAuthError))
|
|
208
|
+
throw err;
|
|
209
|
+
// Refresh token expired or revoked: drop the stale account so the next
|
|
210
|
+
// authenticate run starts clean instead of looping on silent failures.
|
|
211
|
+
await app.getTokenCache().removeAccount(account);
|
|
212
|
+
throw new AuthRequiredError(`The cached session for ${account.username} has expired or was revoked. ` +
|
|
213
|
+
'Run the authenticate tool to sign in again, then retry this request.');
|
|
214
|
+
}
|
|
215
|
+
if (!result)
|
|
216
|
+
throw new Error('Token acquisition returned no result.');
|
|
217
|
+
rememberToken(result);
|
|
218
|
+
return cachedToken.token;
|
|
219
|
+
}
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sanitizeFilename = sanitizeFilename;
|
|
4
|
+
exports.uniquePath = uniquePath;
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
/**
|
|
8
|
+
* Attachment names come from the email sender, so they must be treated as
|
|
9
|
+
* untrusted input: strip any path components (prevents `..\` traversal),
|
|
10
|
+
* control characters, and characters Windows rejects in filenames.
|
|
11
|
+
*/
|
|
12
|
+
function sanitizeFilename(name) {
|
|
13
|
+
const base = Array.from((0, path_1.basename)(name ?? ''))
|
|
14
|
+
.filter((c) => c.charCodeAt(0) >= 32)
|
|
15
|
+
.join('')
|
|
16
|
+
.replace(/[<>:"/\\|?*]/g, '_')
|
|
17
|
+
.replace(/^[. ]+|[. ]+$/g, '');
|
|
18
|
+
return base || 'attachment';
|
|
19
|
+
}
|
|
20
|
+
/** Returns a path in `dir` that doesn't collide with an existing file. */
|
|
21
|
+
function uniquePath(dir, filename) {
|
|
22
|
+
let candidate = (0, path_1.join)(dir, filename);
|
|
23
|
+
if (!(0, fs_1.existsSync)(candidate))
|
|
24
|
+
return candidate;
|
|
25
|
+
const ext = (0, path_1.extname)(filename);
|
|
26
|
+
const stem = filename.slice(0, filename.length - ext.length);
|
|
27
|
+
for (let i = 1;; i++) {
|
|
28
|
+
candidate = (0, path_1.join)(dir, `${stem} (${i})${ext}`);
|
|
29
|
+
if (!(0, fs_1.existsSync)(candidate))
|
|
30
|
+
return candidate;
|
|
31
|
+
}
|
|
32
|
+
}
|
package/dist/mail.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.listEmails = listEmails;
|
|
4
|
+
exports.listFolders = listFolders;
|
|
5
|
+
exports.getEmail = getEmail;
|
|
6
|
+
exports.getEmails = getEmails;
|
|
7
|
+
exports.searchEmails = searchEmails;
|
|
8
|
+
exports.listAttachments = listAttachments;
|
|
9
|
+
exports.downloadAttachment = downloadAttachment;
|
|
10
|
+
const microsoft_graph_client_1 = require("@microsoft/microsoft-graph-client");
|
|
11
|
+
const auth_1 = require("./auth");
|
|
12
|
+
let client = null;
|
|
13
|
+
function buildClient() {
|
|
14
|
+
if (client)
|
|
15
|
+
return client;
|
|
16
|
+
client = microsoft_graph_client_1.Client.initWithMiddleware({
|
|
17
|
+
authProvider: { getAccessToken: auth_1.getAccessToken },
|
|
18
|
+
});
|
|
19
|
+
return client;
|
|
20
|
+
}
|
|
21
|
+
const LIST_FIELDS = 'id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview';
|
|
22
|
+
const DETAIL_FIELDS = 'id,subject,from,toRecipients,receivedDateTime,hasAttachments,body';
|
|
23
|
+
const PREFER_TEXT_BODY = 'outlook.body-content-type="text"';
|
|
24
|
+
async function listEmails(options = {}) {
|
|
25
|
+
const { top = 10, skip, filter, folder = 'inbox', orderby = 'receivedDateTime desc', } = options;
|
|
26
|
+
const client = buildClient();
|
|
27
|
+
let req = client
|
|
28
|
+
.api(`/me/mailFolders/${encodeURIComponent(folder)}/messages`)
|
|
29
|
+
.top(top)
|
|
30
|
+
.orderby(orderby)
|
|
31
|
+
.select(LIST_FIELDS);
|
|
32
|
+
if (skip)
|
|
33
|
+
req = req.skip(skip);
|
|
34
|
+
if (filter)
|
|
35
|
+
req = req.filter(filter);
|
|
36
|
+
const res = await req.get();
|
|
37
|
+
return res.value;
|
|
38
|
+
}
|
|
39
|
+
async function listFolders() {
|
|
40
|
+
const client = buildClient();
|
|
41
|
+
const res = await client
|
|
42
|
+
.api('/me/mailFolders')
|
|
43
|
+
.select('id,displayName,unreadItemCount,totalItemCount')
|
|
44
|
+
.top(50)
|
|
45
|
+
.get();
|
|
46
|
+
return res.value;
|
|
47
|
+
}
|
|
48
|
+
async function getEmail(id) {
|
|
49
|
+
const client = buildClient();
|
|
50
|
+
return client
|
|
51
|
+
.api(`/me/messages/${encodeURIComponent(id)}`)
|
|
52
|
+
.header('Prefer', PREFER_TEXT_BODY)
|
|
53
|
+
.select(DETAIL_FIELDS)
|
|
54
|
+
.get();
|
|
55
|
+
}
|
|
56
|
+
async function getEmails(ids) {
|
|
57
|
+
const client = buildClient();
|
|
58
|
+
const results = [];
|
|
59
|
+
// Graph JSON batching allows max 20 sub-requests per call.
|
|
60
|
+
for (let i = 0; i < ids.length; i += 20) {
|
|
61
|
+
const chunk = ids.slice(i, i + 20);
|
|
62
|
+
const batch = {
|
|
63
|
+
requests: chunk.map((id, idx) => ({
|
|
64
|
+
id: String(idx),
|
|
65
|
+
method: 'GET',
|
|
66
|
+
url: `/me/messages/${encodeURIComponent(id)}?$select=${DETAIL_FIELDS}`,
|
|
67
|
+
headers: { Prefer: PREFER_TEXT_BODY },
|
|
68
|
+
})),
|
|
69
|
+
};
|
|
70
|
+
const res = await client.api('/$batch').post(batch);
|
|
71
|
+
// Responses come back in arbitrary order; re-align to input order via id.
|
|
72
|
+
const byId = new Map(res.responses.map((r) => [r.id, r]));
|
|
73
|
+
chunk.forEach((id, idx) => {
|
|
74
|
+
const r = byId.get(String(idx));
|
|
75
|
+
if (r && r.status >= 200 && r.status < 300) {
|
|
76
|
+
results.push({ ok: true, message: r.body });
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const errBody = r?.body;
|
|
80
|
+
results.push({
|
|
81
|
+
ok: false,
|
|
82
|
+
id,
|
|
83
|
+
error: `HTTP ${r?.status ?? '?'}: ${errBody?.error?.message ?? 'request failed'}`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return results;
|
|
89
|
+
}
|
|
90
|
+
async function searchEmails(query, top = 10) {
|
|
91
|
+
const client = buildClient();
|
|
92
|
+
// Escape embedded quotes so user input can't break the $search phrase syntax.
|
|
93
|
+
const safeQuery = query.replace(/"/g, '\\"');
|
|
94
|
+
const res = await client
|
|
95
|
+
.api('/me/messages')
|
|
96
|
+
.search(`"${safeQuery}"`)
|
|
97
|
+
.top(top)
|
|
98
|
+
.select(LIST_FIELDS)
|
|
99
|
+
.get();
|
|
100
|
+
return res.value;
|
|
101
|
+
}
|
|
102
|
+
async function listAttachments(messageId) {
|
|
103
|
+
const client = buildClient();
|
|
104
|
+
const res = await client
|
|
105
|
+
.api(`/me/messages/${encodeURIComponent(messageId)}/attachments`)
|
|
106
|
+
.select('id,name,size,contentType,isInline')
|
|
107
|
+
.get();
|
|
108
|
+
return res.value;
|
|
109
|
+
}
|
|
110
|
+
async function downloadAttachment(messageId, attachmentId) {
|
|
111
|
+
const client = buildClient();
|
|
112
|
+
const att = await client
|
|
113
|
+
.api(`/me/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}`)
|
|
114
|
+
.get();
|
|
115
|
+
return {
|
|
116
|
+
name: att.name,
|
|
117
|
+
contentBytes: att.contentBytes,
|
|
118
|
+
contentType: att.contentType,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
5
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
6
|
+
require("dotenv/config");
|
|
7
|
+
const auth_js_1 = require("./auth.js");
|
|
8
|
+
const index_js_1 = require("./tools/index.js");
|
|
9
|
+
async function main() {
|
|
10
|
+
// Fail fast with a readable message instead of a confusing MSAL error on
|
|
11
|
+
// the first tool call.
|
|
12
|
+
(0, auth_js_1.getConfig)();
|
|
13
|
+
const server = new mcp_js_1.McpServer({
|
|
14
|
+
name: 'outlook-reader',
|
|
15
|
+
version: '1.0.0',
|
|
16
|
+
});
|
|
17
|
+
(0, index_js_1.registerAllTools)(server);
|
|
18
|
+
const transport = new stdio_js_1.StdioServerTransport();
|
|
19
|
+
await server.connect(transport);
|
|
20
|
+
// When the client disconnects, stdin ends — but background MSAL device-code
|
|
21
|
+
// polling can keep the event loop alive for up to 15 minutes, leaving an
|
|
22
|
+
// orphaned process. Give in-flight responses a moment to flush, then exit.
|
|
23
|
+
// unref() lets the process exit sooner naturally if the loop drains.
|
|
24
|
+
process.stdin.on('end', () => {
|
|
25
|
+
setTimeout(() => process.exit(0), 5_000).unref();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
main().catch((err) => {
|
|
29
|
+
console.error(err instanceof Error ? err.message : err);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.description = void 0;
|
|
4
|
+
exports.handler = handler;
|
|
5
|
+
const auth_js_1 = require("../../auth.js");
|
|
6
|
+
exports.description = 'Sign in to the Microsoft account used by the other tools. Returns a URL and a one-time code: show both to the user and tell them to complete the sign-in in their browser. Run this when other tools report that authentication is required. Safe to call anytime — reports the current account if already signed in.';
|
|
7
|
+
async function handler(_input) {
|
|
8
|
+
const account = await (0, auth_js_1.getSignedInAccount)();
|
|
9
|
+
if (account) {
|
|
10
|
+
return {
|
|
11
|
+
content: [
|
|
12
|
+
{
|
|
13
|
+
type: 'text',
|
|
14
|
+
text: `Already signed in as ${account}. Other tools are ready to use.`,
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const verification = await (0, auth_js_1.startDeviceCodeAuth)();
|
|
20
|
+
return {
|
|
21
|
+
content: [
|
|
22
|
+
{
|
|
23
|
+
type: 'text',
|
|
24
|
+
text: `${verification}\n\n` +
|
|
25
|
+
'Show the URL and code to the user and ask them to complete the sign-in ' +
|
|
26
|
+
'in their browser. Once done, retry the original request — no need to ' +
|
|
27
|
+
'run this tool again.',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.register = register;
|
|
4
|
+
const wrap_js_1 = require("../wrap.js");
|
|
5
|
+
const definition_js_1 = require("./definition.js");
|
|
6
|
+
const key_js_1 = require("./key.js");
|
|
7
|
+
const schema_js_1 = require("./schema.js");
|
|
8
|
+
function register(server) {
|
|
9
|
+
server.registerTool(key_js_1.KEY, { description: definition_js_1.description, inputSchema: schema_js_1.schema }, (0, wrap_js_1.wrapHandler)(definition_js_1.handler));
|
|
10
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.description = void 0;
|
|
4
|
+
exports.handler = handler;
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const os_1 = require("os");
|
|
7
|
+
const path_1 = require("path");
|
|
8
|
+
const files_js_1 = require("../../files.js");
|
|
9
|
+
const mail_js_1 = require("../../mail.js");
|
|
10
|
+
exports.description = 'Download a specific email attachment and save it to disk. Use list_attachments first to get the attachment ID. To save several (or all) attachments from an email in one call, use download_attachments instead. Returns the full path where the file was saved.';
|
|
11
|
+
async function handler({ messageId, attachmentId, saveTo }) {
|
|
12
|
+
const att = await (0, mail_js_1.downloadAttachment)(messageId, attachmentId);
|
|
13
|
+
if (!att.contentBytes) {
|
|
14
|
+
return {
|
|
15
|
+
content: [
|
|
16
|
+
{
|
|
17
|
+
type: 'text',
|
|
18
|
+
text: 'Attachment has no downloadable content (may be an item attachment, not a file).',
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const dir = saveTo ?? (0, path_1.join)((0, os_1.homedir)(), 'Downloads');
|
|
24
|
+
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
25
|
+
const buf = Buffer.from(att.contentBytes, 'base64');
|
|
26
|
+
const filePath = (0, files_js_1.uniquePath)(dir, (0, files_js_1.sanitizeFilename)(att.name));
|
|
27
|
+
(0, fs_1.writeFileSync)(filePath, buf);
|
|
28
|
+
return {
|
|
29
|
+
content: [
|
|
30
|
+
{
|
|
31
|
+
type: 'text',
|
|
32
|
+
text: `Saved: ${filePath}\nSize: ${(buf.length / 1024).toFixed(1)} KB\nType: ${att.contentType}`,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.register = register;
|
|
4
|
+
const wrap_js_1 = require("../wrap.js");
|
|
5
|
+
const definition_js_1 = require("./definition.js");
|
|
6
|
+
const key_js_1 = require("./key.js");
|
|
7
|
+
const schema_js_1 = require("./schema.js");
|
|
8
|
+
function register(server) {
|
|
9
|
+
server.registerTool(key_js_1.KEY, { description: definition_js_1.description, inputSchema: schema_js_1.schema }, (0, wrap_js_1.wrapHandler)(definition_js_1.handler));
|
|
10
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.schema = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
exports.schema = zod_1.z.object({
|
|
6
|
+
messageId: zod_1.z.string().describe('The email message ID'),
|
|
7
|
+
attachmentId: zod_1.z
|
|
8
|
+
.string()
|
|
9
|
+
.describe('The attachment ID (from list_attachments)'),
|
|
10
|
+
saveTo: zod_1.z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe('Directory path to save the file. Defaults to user Downloads folder (e.g. C:\\Users\\username\\Downloads)'),
|
|
14
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.description = void 0;
|
|
4
|
+
exports.handler = handler;
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const os_1 = require("os");
|
|
7
|
+
const path_1 = require("path");
|
|
8
|
+
const files_js_1 = require("../../files.js");
|
|
9
|
+
const mail_js_1 = require("../../mail.js");
|
|
10
|
+
exports.description = 'Download multiple attachments from an email in a single call and save them to disk. Omit attachmentIds to download all file attachments. Returns the saved path for each file.';
|
|
11
|
+
async function handler({ messageId, attachmentIds, saveTo, includeInline, }) {
|
|
12
|
+
const all = await (0, mail_js_1.listAttachments)(messageId);
|
|
13
|
+
const lines = [];
|
|
14
|
+
let targets;
|
|
15
|
+
if (attachmentIds?.length) {
|
|
16
|
+
const known = new Set(all.map((a) => a.id));
|
|
17
|
+
for (const id of attachmentIds) {
|
|
18
|
+
if (!known.has(id))
|
|
19
|
+
lines.push(`Not found: attachment ID ${id}`);
|
|
20
|
+
}
|
|
21
|
+
targets = all.filter((a) => attachmentIds.includes(a.id));
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
targets = all.filter((a) => includeInline || !a.isInline);
|
|
25
|
+
}
|
|
26
|
+
if (targets.length === 0) {
|
|
27
|
+
lines.push('No attachments to download.');
|
|
28
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
29
|
+
}
|
|
30
|
+
const dir = saveTo ?? (0, path_1.join)((0, os_1.homedir)(), 'Downloads');
|
|
31
|
+
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
32
|
+
// Sequential on purpose: uniquePath checks disk, so parallel downloads of
|
|
33
|
+
// same-named attachments could race into the same target path.
|
|
34
|
+
for (const a of targets) {
|
|
35
|
+
try {
|
|
36
|
+
const att = await (0, mail_js_1.downloadAttachment)(messageId, a.id);
|
|
37
|
+
if (!att.contentBytes) {
|
|
38
|
+
lines.push(`Skipped: ${a.name} (no downloadable content — item attachment, not a file)`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const buf = Buffer.from(att.contentBytes, 'base64');
|
|
42
|
+
const filePath = (0, files_js_1.uniquePath)(dir, (0, files_js_1.sanitizeFilename)(att.name ?? a.name));
|
|
43
|
+
(0, fs_1.writeFileSync)(filePath, buf);
|
|
44
|
+
lines.push(`Saved: ${filePath} (${(buf.length / 1024).toFixed(1)} KB)`);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
lines.push(`Failed: ${a.name} — ${err instanceof Error ? err.message : String(err)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
51
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.register = register;
|
|
4
|
+
const wrap_js_1 = require("../wrap.js");
|
|
5
|
+
const definition_js_1 = require("./definition.js");
|
|
6
|
+
const key_js_1 = require("./key.js");
|
|
7
|
+
const schema_js_1 = require("./schema.js");
|
|
8
|
+
function register(server) {
|
|
9
|
+
server.registerTool(key_js_1.KEY, { description: definition_js_1.description, inputSchema: schema_js_1.schema }, (0, wrap_js_1.wrapHandler)(definition_js_1.handler));
|
|
10
|
+
}
|