outlook-teams-mcp 0.1.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 +21 -0
- package/README.md +110 -0
- package/dist/auth.js +97 -0
- package/dist/cache.js +107 -0
- package/dist/config.js +46 -0
- package/dist/confirm.js +63 -0
- package/dist/format.js +236 -0
- package/dist/graph.js +75 -0
- package/dist/index.js +67 -0
- package/dist/login-tools.js +92 -0
- package/dist/tools/common.js +112 -0
- package/dist/tools/files.js +133 -0
- package/dist/tools/index.js +24 -0
- package/dist/tools/mail.js +87 -0
- package/dist/tools/people.js +26 -0
- package/dist/tools/teams-read.js +164 -0
- package/dist/tools/teams-write.js +202 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yamen Sharaf
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# outlook-teams-mcp
|
|
2
|
+
|
|
3
|
+
A local MCP server that lets your AI assistant read and work with your **Outlook mail** and **Microsoft Teams**,
|
|
4
|
+
signed in as you. No secrets and no app registration needed. Calendar isn't supported.
|
|
5
|
+
|
|
6
|
+
## What it can do
|
|
7
|
+
|
|
8
|
+
- **Mail:** list, search and read emails; create drafts and reply drafts (it never sends mail).
|
|
9
|
+
- **Teams:** read chats and channels (including bot alerts like Datadog), search messages, find your @mentions,
|
|
10
|
+
look up people.
|
|
11
|
+
- **Teams actions:** send messages (with @mentions), post and reply in channels, start chats, edit or delete your
|
|
12
|
+
own messages, share files and react.
|
|
13
|
+
|
|
14
|
+
Anything that sends or changes something shows you a **preview first** and only goes through after you approve
|
|
15
|
+
it. Reactions are the exception: they happen in one step.
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
Requires Node 20+. Sign in once (this opens your browser):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx -y outlook-teams-mcp --login
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then add it to your AI client (examples below). For read-only access, add the environment variable
|
|
26
|
+
`OUTLOOK_TEAMS_MCP_READ_ONLY=1`; the sending tools then don't exist at all.
|
|
27
|
+
|
|
28
|
+
### Claude Code
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
claude mcp add outlook-teams -s user -- npx -y outlook-teams-mcp
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Read-only:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
claude mcp add outlook-teams -s user -e OUTLOOK_TEAMS_MCP_READ_ONLY=1 -- npx -y outlook-teams-mcp
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Claude Desktop, Cursor and other `mcpServers` clients
|
|
41
|
+
|
|
42
|
+
Claude Desktop: `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or
|
|
43
|
+
`%APPDATA%\Claude\claude_desktop_config.json` (Windows). Cursor: `~/.cursor/mcp.json`.
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"mcpServers": {
|
|
48
|
+
"outlook-teams": {
|
|
49
|
+
"command": "npx",
|
|
50
|
+
"args": ["-y", "outlook-teams-mcp"]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Read-only:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"mcpServers": {
|
|
61
|
+
"outlook-teams": {
|
|
62
|
+
"command": "npx",
|
|
63
|
+
"args": ["-y", "outlook-teams-mcp"],
|
|
64
|
+
"env": { "OUTLOOK_TEAMS_MCP_READ_ONLY": "1" }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### VS Code
|
|
71
|
+
|
|
72
|
+
In `.vscode/mcp.json` (one workspace) or your user `mcp.json`:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"servers": {
|
|
77
|
+
"outlook-teams": {
|
|
78
|
+
"type": "stdio",
|
|
79
|
+
"command": "npx",
|
|
80
|
+
"args": ["-y", "outlook-teams-mcp"]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
> **"spawn npx ENOENT"?** Desktop apps sometimes can't find `npx`. Use the full path instead, from
|
|
87
|
+
> `which npx` (e.g. `/opt/homebrew/bin/npx`).
|
|
88
|
+
|
|
89
|
+
Keep the sending tools on **ask** in your client, not "always allow".
|
|
90
|
+
|
|
91
|
+
## Everyday use
|
|
92
|
+
|
|
93
|
+
- **Signed out?** The assistant will tell you and can reopen the sign-in page, but only when your sign-in has
|
|
94
|
+
actually expired. You can also run `npx -y outlook-teams-mcp --login` yourself.
|
|
95
|
+
- **Sign out completely:** `npx -y outlook-teams-mcp --logout` removes the stored login from this machine.
|
|
96
|
+
- **New machine:** sign in there too. Logins are never copied between machines.
|
|
97
|
+
- **"Approval required" at sign-in?** Your organization hasn't approved some of the permissions this server asks
|
|
98
|
+
for. You can limit them with `OUTLOOK_TEAMS_MCP_SCOPES` (see [AGENTS.md](AGENTS.md)) or ask your IT team.
|
|
99
|
+
|
|
100
|
+
## Running from source
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
git clone https://github.com/YamenSharaf/outlook-teams-mcp.git && cd outlook-teams-mcp
|
|
104
|
+
npm install && npm run build
|
|
105
|
+
node dist/index.js --login
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Then use `node /path/to/outlook-teams-mcp/dist/index.js` as the command instead of `npx -y outlook-teams-mcp`.
|
|
109
|
+
|
|
110
|
+
Technical details for agents and contributors are in [AGENTS.md](AGENTS.md).
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { PublicClientApplication } from "@azure/msal-node";
|
|
3
|
+
import { AUTHORITY, CLIENT_ID, SCOPES } from "./config.js";
|
|
4
|
+
import { cachePlugin, clearCache } from "./cache.js";
|
|
5
|
+
const pca = new PublicClientApplication({
|
|
6
|
+
auth: { clientId: CLIENT_ID, authority: AUTHORITY },
|
|
7
|
+
cache: { cachePlugin },
|
|
8
|
+
});
|
|
9
|
+
async function currentAccount() {
|
|
10
|
+
const accounts = await pca.getTokenCache().getAllAccounts();
|
|
11
|
+
const wanted = process.env.OUTLOOK_TEAMS_MCP_USERNAME?.toLowerCase();
|
|
12
|
+
if (wanted)
|
|
13
|
+
return accounts.find((a) => a.username.toLowerCase() === wanted);
|
|
14
|
+
return accounts[0];
|
|
15
|
+
}
|
|
16
|
+
export class NotLoggedInError extends Error {
|
|
17
|
+
constructor() {
|
|
18
|
+
super("Microsoft 365 sign-in has expired or is missing. Tell the user, and with their OK call the `login` tool " +
|
|
19
|
+
"(or they can run `outlook-teams-mcp --login` in a terminal).");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Silent token acquisition only (cache, then refresh token). Never opens anything. */
|
|
23
|
+
export async function getAccessToken() {
|
|
24
|
+
const account = await currentAccount();
|
|
25
|
+
if (!account)
|
|
26
|
+
throw new NotLoggedInError();
|
|
27
|
+
try {
|
|
28
|
+
const result = await pca.acquireTokenSilent({ account, scopes: SCOPES });
|
|
29
|
+
return result.accessToken;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
const name = err.name ?? "";
|
|
33
|
+
if (name === "InteractionRequiredAuthError")
|
|
34
|
+
throw new NotLoggedInError();
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function authStatus() {
|
|
39
|
+
try {
|
|
40
|
+
await getAccessToken();
|
|
41
|
+
const account = await currentAccount();
|
|
42
|
+
return { signedIn: true, username: account?.username ?? "unknown" };
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return { signedIn: false, reason: err.message };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// ---------------------------------------------------------------- interactive sign-in
|
|
49
|
+
const LOGIN_HOSTS = new Set(["login.microsoftonline.com", "login.microsoft.com"]);
|
|
50
|
+
/** Opens a URL in the default browser, but only Microsoft sign-in URLs. */
|
|
51
|
+
export function openSignInUrl(url) {
|
|
52
|
+
const host = new URL(url).hostname;
|
|
53
|
+
if (new URL(url).protocol !== "https:" || !LOGIN_HOSTS.has(host)) {
|
|
54
|
+
return Promise.reject(new Error(`Refusing to open non-Microsoft sign-in URL (${host}).`));
|
|
55
|
+
}
|
|
56
|
+
const [cmd, args] = process.platform === "darwin"
|
|
57
|
+
? ["open", [url]]
|
|
58
|
+
: process.platform === "win32"
|
|
59
|
+
? ["rundll32", ["url.dll,FileProtocolHandler", url]]
|
|
60
|
+
: ["xdg-open", [url]];
|
|
61
|
+
return new Promise((resolve, reject) => execFile(cmd, args, (err) => (err ? reject(err) : resolve())));
|
|
62
|
+
}
|
|
63
|
+
const SUCCESS_PAGE = "<html><body style='font-family:system-ui;margin:3em'><h2>Signed in to outlook-teams-mcp</h2><p>You can close this tab.</p></body></html>";
|
|
64
|
+
const ERROR_PAGE = "<html><body style='font-family:system-ui;margin:3em'><h2>outlook-teams-mcp sign-in failed</h2><p>{{error}}: {{errorDescription}}</p></body></html>";
|
|
65
|
+
/** Browser sign-in (auth code + PKCE, loopback redirect). No code to type. */
|
|
66
|
+
export async function loginInteractive() {
|
|
67
|
+
const account = await currentAccount();
|
|
68
|
+
const result = await pca.acquireTokenInteractive({
|
|
69
|
+
scopes: SCOPES,
|
|
70
|
+
openBrowser: openSignInUrl,
|
|
71
|
+
successTemplate: SUCCESS_PAGE,
|
|
72
|
+
errorTemplate: ERROR_PAGE,
|
|
73
|
+
...(account ? { loginHint: account.username } : { prompt: "select_account" }),
|
|
74
|
+
});
|
|
75
|
+
if (!result?.account)
|
|
76
|
+
throw new Error("Login did not return an account.");
|
|
77
|
+
return result.account;
|
|
78
|
+
}
|
|
79
|
+
/** Device-code sign-in. `onCode` receives the user-facing message (URL + code). */
|
|
80
|
+
export async function loginDeviceCode(onCode) {
|
|
81
|
+
const result = await pca.acquireTokenByDeviceCode({
|
|
82
|
+
scopes: SCOPES,
|
|
83
|
+
deviceCodeCallback: (r) => onCode({ message: r.message, userCode: r.userCode, verificationUri: r.verificationUri }),
|
|
84
|
+
});
|
|
85
|
+
if (!result?.account)
|
|
86
|
+
throw new Error("Login did not return an account.");
|
|
87
|
+
return result.account;
|
|
88
|
+
}
|
|
89
|
+
export async function whoAmI() {
|
|
90
|
+
return currentAccount();
|
|
91
|
+
}
|
|
92
|
+
export async function logout() {
|
|
93
|
+
const cache = pca.getTokenCache();
|
|
94
|
+
for (const account of await cache.getAllAccounts())
|
|
95
|
+
await cache.removeAccount(account);
|
|
96
|
+
await clearCache();
|
|
97
|
+
}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CONFIG_DIR, KEY_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE, TOKEN_CACHE_FILE } from "./config.js";
|
|
5
|
+
let keytarPromise;
|
|
6
|
+
function loadKeytar() {
|
|
7
|
+
if (process.env.OUTLOOK_TEAMS_MCP_USE_KEYTAR === "0")
|
|
8
|
+
return Promise.resolve(null);
|
|
9
|
+
keytarPromise ??= import("keytar").then((m) => (m.default ?? m)).catch(() => null);
|
|
10
|
+
return keytarPromise;
|
|
11
|
+
}
|
|
12
|
+
function ensureDir() {
|
|
13
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
14
|
+
}
|
|
15
|
+
function writePrivate(file, data) {
|
|
16
|
+
ensureDir();
|
|
17
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
18
|
+
fs.writeFileSync(tmp, data, { mode: 0o600 });
|
|
19
|
+
fs.renameSync(tmp, file);
|
|
20
|
+
}
|
|
21
|
+
let cachedKey;
|
|
22
|
+
async function getKey(create) {
|
|
23
|
+
if (cachedKey)
|
|
24
|
+
return cachedKey;
|
|
25
|
+
const keytar = await loadKeytar();
|
|
26
|
+
let hex = null;
|
|
27
|
+
if (keytar) {
|
|
28
|
+
try {
|
|
29
|
+
hex = await keytar.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
hex = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (!hex && fs.existsSync(KEY_FILE))
|
|
36
|
+
hex = fs.readFileSync(KEY_FILE, "utf8").trim();
|
|
37
|
+
if (!hex) {
|
|
38
|
+
if (!create)
|
|
39
|
+
return undefined;
|
|
40
|
+
hex = crypto.randomBytes(32).toString("hex");
|
|
41
|
+
let stored = false;
|
|
42
|
+
if (keytar) {
|
|
43
|
+
try {
|
|
44
|
+
await keytar.setPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, hex);
|
|
45
|
+
stored = true;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
stored = false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!stored)
|
|
52
|
+
writePrivate(KEY_FILE, hex);
|
|
53
|
+
}
|
|
54
|
+
cachedKey = Buffer.from(hex, "hex");
|
|
55
|
+
return cachedKey;
|
|
56
|
+
}
|
|
57
|
+
function encrypt(key, plaintext) {
|
|
58
|
+
const iv = crypto.randomBytes(12);
|
|
59
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
60
|
+
const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
61
|
+
const tag = cipher.getAuthTag();
|
|
62
|
+
return JSON.stringify({ v: 1, iv: iv.toString("base64"), tag: tag.toString("base64"), data: data.toString("base64") });
|
|
63
|
+
}
|
|
64
|
+
function decrypt(key, payload) {
|
|
65
|
+
const { iv, tag, data } = JSON.parse(payload);
|
|
66
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64"));
|
|
67
|
+
decipher.setAuthTag(Buffer.from(tag, "base64"));
|
|
68
|
+
return Buffer.concat([decipher.update(Buffer.from(data, "base64")), decipher.final()]).toString("utf8");
|
|
69
|
+
}
|
|
70
|
+
export const cachePlugin = {
|
|
71
|
+
async beforeCacheAccess(ctx) {
|
|
72
|
+
if (!fs.existsSync(TOKEN_CACHE_FILE))
|
|
73
|
+
return;
|
|
74
|
+
const key = await getKey(false);
|
|
75
|
+
if (!key)
|
|
76
|
+
return; // key gone (e.g. keychain cleared): behave as logged out
|
|
77
|
+
try {
|
|
78
|
+
ctx.tokenCache.deserialize(decrypt(key, fs.readFileSync(TOKEN_CACHE_FILE, "utf8")));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Corrupt or undecryptable cache: ignore; a fresh --login will overwrite it.
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
async afterCacheAccess(ctx) {
|
|
85
|
+
if (!ctx.cacheHasChanged)
|
|
86
|
+
return;
|
|
87
|
+
const key = await getKey(true);
|
|
88
|
+
writePrivate(TOKEN_CACHE_FILE, encrypt(key, ctx.tokenCache.serialize()));
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
export async function clearCache() {
|
|
92
|
+
fs.rmSync(TOKEN_CACHE_FILE, { force: true });
|
|
93
|
+
fs.rmSync(KEY_FILE, { force: true });
|
|
94
|
+
const keytar = await loadKeytar();
|
|
95
|
+
if (keytar) {
|
|
96
|
+
try {
|
|
97
|
+
await keytar.deletePassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// nothing stored
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
cachedKey = undefined;
|
|
104
|
+
}
|
|
105
|
+
export function cacheLocation() {
|
|
106
|
+
return path.resolve(TOKEN_CACHE_FILE);
|
|
107
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// Microsoft's first-party "Microsoft Graph Command Line Tools" public client.
|
|
4
|
+
// Override with your own app registration via OUTLOOK_TEAMS_MCP_CLIENT_ID / OUTLOOK_TEAMS_MCP_TENANT_ID.
|
|
5
|
+
export const CLIENT_ID = process.env.OUTLOOK_TEAMS_MCP_CLIENT_ID || "14d82eec-204b-4c2f-b7e8-296a70dab67e";
|
|
6
|
+
export const TENANT_ID = process.env.OUTLOOK_TEAMS_MCP_TENANT_ID || "organizations";
|
|
7
|
+
export const AUTHORITY = `https://login.microsoftonline.com/${TENANT_ID}`;
|
|
8
|
+
export const GRAPH_BASE = "https://graph.microsoft.com/v1.0";
|
|
9
|
+
// Default delegated scopes. In tenants that block user consent, every requested scope must already be approved
|
|
10
|
+
// for the client above, or login fails with "Approval required". Override with OUTLOOK_TEAMS_MCP_SCOPES.
|
|
11
|
+
const DEFAULT_SCOPES = [
|
|
12
|
+
"User.Read",
|
|
13
|
+
"User.ReadBasic.All", // people lookup, @mentions
|
|
14
|
+
"Mail.Read",
|
|
15
|
+
"Mail.ReadWrite", // drafts only; no send/delete/move mail tools are exposed
|
|
16
|
+
"Chat.ReadWrite", // read chats, send/edit/delete own chat messages, create chats, reactions
|
|
17
|
+
"ChatMessage.Read",
|
|
18
|
+
"ChatMember.ReadWrite", // add people to group chats
|
|
19
|
+
"Team.ReadBasic.All",
|
|
20
|
+
"TeamMember.Read.All",
|
|
21
|
+
"Channel.ReadBasic.All",
|
|
22
|
+
"ChannelMessage.Read.All",
|
|
23
|
+
"ChannelMessage.Send",
|
|
24
|
+
"ChannelMessage.ReadWrite", // edit/delete own channel messages
|
|
25
|
+
"Files.ReadWrite.All", // upload files shared into chats/channels
|
|
26
|
+
];
|
|
27
|
+
export const SCOPES = process.env.OUTLOOK_TEAMS_MCP_SCOPES
|
|
28
|
+
? process.env.OUTLOOK_TEAMS_MCP_SCOPES.split(/[\s,]+/).filter(Boolean)
|
|
29
|
+
: DEFAULT_SCOPES;
|
|
30
|
+
function configDir() {
|
|
31
|
+
if (process.env.OUTLOOK_TEAMS_MCP_CONFIG_DIR)
|
|
32
|
+
return process.env.OUTLOOK_TEAMS_MCP_CONFIG_DIR;
|
|
33
|
+
switch (process.platform) {
|
|
34
|
+
case "darwin":
|
|
35
|
+
return path.join(os.homedir(), "Library", "Application Support", "outlook-teams-mcp");
|
|
36
|
+
case "win32":
|
|
37
|
+
return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "outlook-teams-mcp");
|
|
38
|
+
default:
|
|
39
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "outlook-teams-mcp");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export const CONFIG_DIR = configDir();
|
|
43
|
+
export const TOKEN_CACHE_FILE = path.join(CONFIG_DIR, "token-cache.enc");
|
|
44
|
+
export const KEY_FILE = path.join(CONFIG_DIR, ".cache-key"); // only used when no OS credential store
|
|
45
|
+
export const KEYCHAIN_SERVICE = "outlook-teams-mcp";
|
|
46
|
+
export const KEYCHAIN_ACCOUNT = "token-cache-key";
|
package/dist/confirm.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
const TOKEN_TTL_MS = 5 * 60 * 1000;
|
|
3
|
+
const pending = new Map();
|
|
4
|
+
function fingerprint(tool, args) {
|
|
5
|
+
const { confirmationToken: _ignored, ...rest } = args;
|
|
6
|
+
const canonical = JSON.stringify(rest, Object.keys(rest).sort());
|
|
7
|
+
return crypto.createHash("sha256").update(`${tool}\n${canonical}`).digest("hex");
|
|
8
|
+
}
|
|
9
|
+
function preview(req) {
|
|
10
|
+
return `${req.action}\nTo: ${req.destination}\n---\n${req.content}\n---`;
|
|
11
|
+
}
|
|
12
|
+
async function viaElicitation(server, req) {
|
|
13
|
+
if (!server.server.getClientCapabilities()?.elicitation)
|
|
14
|
+
return false;
|
|
15
|
+
const res = await server.server.elicitInput({
|
|
16
|
+
mode: "form",
|
|
17
|
+
message: `outlook-teams-mcp wants to write on your behalf:\n\n${preview(req)}`,
|
|
18
|
+
requestedSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: { confirm: { type: "boolean", title: `Yes, ${req.action.toLowerCase()} exactly as shown`, default: false } },
|
|
21
|
+
required: ["confirm"],
|
|
22
|
+
},
|
|
23
|
+
}, { timeout: 120_000 });
|
|
24
|
+
return res.action === "accept" && res.content?.confirm === true;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Runs `perform` only after explicit approval. Returns the text for the tool result.
|
|
28
|
+
* `args` are the tool's raw arguments (including any confirmationToken).
|
|
29
|
+
*/
|
|
30
|
+
export async function guardedWrite(server, tool, args, req, perform) {
|
|
31
|
+
if ((process.env.OUTLOOK_TEAMS_MCP_CONFIRM ?? "").toLowerCase() === "elicitation") {
|
|
32
|
+
if (!(await viaElicitation(server, req)))
|
|
33
|
+
return "NOT done — the user did not approve in the confirmation form. Do not retry unless the user asks.";
|
|
34
|
+
return `${await perform()}\n(approved by the user in the confirmation form)`;
|
|
35
|
+
}
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
for (const [t, p] of pending)
|
|
38
|
+
if (p.expiresAt < now)
|
|
39
|
+
pending.delete(t);
|
|
40
|
+
const token = typeof args.confirmationToken === "string" ? args.confirmationToken : undefined;
|
|
41
|
+
const fp = fingerprint(tool, args);
|
|
42
|
+
if (!token) {
|
|
43
|
+
const newToken = crypto.randomBytes(9).toString("base64url");
|
|
44
|
+
pending.set(newToken, { fingerprint: fp, expiresAt: now + TOKEN_TTL_MS });
|
|
45
|
+
return [
|
|
46
|
+
"PREVIEW ONLY — nothing has been written yet.",
|
|
47
|
+
"",
|
|
48
|
+
preview(req),
|
|
49
|
+
"",
|
|
50
|
+
"Show this exact preview (destination and full text) to the user and ask for explicit approval.",
|
|
51
|
+
`Only if the user clearly approves (e.g. "yes, send it"), call ${tool} again with IDENTICAL arguments plus confirmationToken="${newToken}" (valid 5 minutes, single use).`,
|
|
52
|
+
"If the user wants changes, call without a token again to get a new preview. Never approve on the user's behalf.",
|
|
53
|
+
].join("\n");
|
|
54
|
+
}
|
|
55
|
+
const entry = pending.get(token);
|
|
56
|
+
if (!entry)
|
|
57
|
+
return "NOT done — unknown or expired confirmationToken. Call again without a token to get a fresh preview for the user.";
|
|
58
|
+
if (entry.fingerprint !== fp) {
|
|
59
|
+
return "NOT done — the arguments differ from the preview the user approved. Call again without a token to get a new preview and ask the user again.";
|
|
60
|
+
}
|
|
61
|
+
pending.delete(token);
|
|
62
|
+
return `${await perform()}\n(user-approved preview executed)`;
|
|
63
|
+
}
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Turns Graph mail/Teams payloads into compact plain text for the model.
|
|
2
|
+
// Bot, Workflow and webhook posts often have an empty body and carry their content in
|
|
3
|
+
// attachments (Adaptive Cards, O365 connector cards, hero cards), so those are rendered too.
|
|
4
|
+
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
|
|
5
|
+
export function htmlToText(html) {
|
|
6
|
+
if (!html)
|
|
7
|
+
return "";
|
|
8
|
+
return html
|
|
9
|
+
.replace(/<attachment[^>]*>\s*<\/attachment>/gi, "")
|
|
10
|
+
.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, "")
|
|
11
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
12
|
+
.replace(/<li[^>]*>/gi, "\n- ")
|
|
13
|
+
.replace(/<\/(p|div|tr|h[1-6]|blockquote|ul|ol|table)>/gi, "\n")
|
|
14
|
+
.replace(/<(td|th)[^>]*>/gi, " | ")
|
|
15
|
+
.replace(/<img[^>]*src="[^"]*\/hostedContents\/([^/"]+)\/\$value"[^>]*>/gi, "[image hostedContentId=$1]")
|
|
16
|
+
.replace(/<img[^>]*alt="([^"]*)"[^>]*>/gi, "[image: $1]")
|
|
17
|
+
.replace(/<img[^>]*>/gi, "[image]")
|
|
18
|
+
.replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (_m, href, text) => {
|
|
19
|
+
const t = text.replace(/<[^>]+>/g, "").trim();
|
|
20
|
+
return t && t !== href ? `${t} (${href})` : href;
|
|
21
|
+
})
|
|
22
|
+
.replace(/<[^>]+>/g, "")
|
|
23
|
+
.replace(/&(#x?[0-9a-f]+|\w+);/gi, (m, e) => {
|
|
24
|
+
if (e[0] === "#") {
|
|
25
|
+
const code = e[1].toLowerCase() === "x" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
26
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : m;
|
|
27
|
+
}
|
|
28
|
+
return ENTITIES[e.toLowerCase()] ?? m;
|
|
29
|
+
})
|
|
30
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
31
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
32
|
+
.trim();
|
|
33
|
+
}
|
|
34
|
+
function parseContent(content) {
|
|
35
|
+
if (typeof content !== "string")
|
|
36
|
+
return content;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(content);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return content;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Walks an Adaptive Card element tree and collects its visible text. */
|
|
45
|
+
function adaptiveToText(node, out) {
|
|
46
|
+
if (!node || typeof node !== "object")
|
|
47
|
+
return;
|
|
48
|
+
if (Array.isArray(node)) {
|
|
49
|
+
for (const n of node)
|
|
50
|
+
adaptiveToText(n, out);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
switch (node.type) {
|
|
54
|
+
case "TextBlock":
|
|
55
|
+
if (node.text)
|
|
56
|
+
out.push(String(node.text));
|
|
57
|
+
break;
|
|
58
|
+
case "RichTextBlock":
|
|
59
|
+
out.push((node.inlines ?? []).map((i) => (typeof i === "string" ? i : (i.text ?? ""))).join(""));
|
|
60
|
+
break;
|
|
61
|
+
case "FactSet":
|
|
62
|
+
for (const f of node.facts ?? [])
|
|
63
|
+
out.push(`${String(f.title ?? "").replace(/:\s*$/, "")}: ${f.value ?? ""}`);
|
|
64
|
+
break;
|
|
65
|
+
case "Image":
|
|
66
|
+
if (node.altText)
|
|
67
|
+
out.push(`[image: ${node.altText}]`);
|
|
68
|
+
break;
|
|
69
|
+
case "Action.OpenUrl":
|
|
70
|
+
out.push(`[${node.title ?? "link"}](${node.url})`);
|
|
71
|
+
break;
|
|
72
|
+
case "Action.ShowCard":
|
|
73
|
+
if (node.title)
|
|
74
|
+
out.push(`[${node.title}]`);
|
|
75
|
+
adaptiveToText(node.card, out);
|
|
76
|
+
return;
|
|
77
|
+
case "Action.Submit":
|
|
78
|
+
case "Action.Execute":
|
|
79
|
+
if (node.title)
|
|
80
|
+
out.push(`[button: ${node.title}]`);
|
|
81
|
+
break;
|
|
82
|
+
case "Table":
|
|
83
|
+
for (const row of node.rows ?? []) {
|
|
84
|
+
const cells = (row.cells ?? []).map((c) => {
|
|
85
|
+
const parts = [];
|
|
86
|
+
adaptiveToText(c.items, parts);
|
|
87
|
+
return parts.join(" ");
|
|
88
|
+
});
|
|
89
|
+
out.push(cells.join(" | "));
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
for (const key of ["body", "items", "columns", "actions", "inlines"]) {
|
|
94
|
+
if (key === "inlines" && node.type === "RichTextBlock")
|
|
95
|
+
continue;
|
|
96
|
+
if (node[key])
|
|
97
|
+
adaptiveToText(node[key], out);
|
|
98
|
+
}
|
|
99
|
+
if (node.selectAction?.url)
|
|
100
|
+
out.push(`(${node.selectAction.url})`);
|
|
101
|
+
}
|
|
102
|
+
function connectorToText(card) {
|
|
103
|
+
const out = [];
|
|
104
|
+
if (card.title)
|
|
105
|
+
out.push(String(card.title));
|
|
106
|
+
if (card.summary && card.summary !== card.title)
|
|
107
|
+
out.push(String(card.summary));
|
|
108
|
+
if (card.text)
|
|
109
|
+
out.push(htmlToText(card.text));
|
|
110
|
+
for (const s of card.sections ?? []) {
|
|
111
|
+
for (const k of ["activityTitle", "activitySubtitle", "activityText", "title", "text"]) {
|
|
112
|
+
if (s[k])
|
|
113
|
+
out.push(htmlToText(String(s[k])));
|
|
114
|
+
}
|
|
115
|
+
for (const f of s.facts ?? [])
|
|
116
|
+
out.push(`${f.name ?? ""}: ${htmlToText(String(f.value ?? ""))}`);
|
|
117
|
+
}
|
|
118
|
+
for (const a of card.potentialAction ?? []) {
|
|
119
|
+
const uri = a.targets?.[0]?.uri ?? a.target?.[0];
|
|
120
|
+
out.push(uri ? `[${a.name}](${uri})` : `[${a.name}]`);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
function heroToText(card) {
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const k of ["title", "subtitle", "text"])
|
|
127
|
+
if (card[k])
|
|
128
|
+
out.push(htmlToText(String(card[k])));
|
|
129
|
+
for (const b of card.buttons ?? [])
|
|
130
|
+
out.push(b.value ? `[${b.title}](${b.value})` : `[${b.title}]`);
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
export function attachmentsToText(attachments) {
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const a of attachments ?? []) {
|
|
136
|
+
const ct = String(a.contentType ?? "");
|
|
137
|
+
const content = parseContent(a.content);
|
|
138
|
+
if (ct === "application/vnd.microsoft.card.adaptive") {
|
|
139
|
+
const parts = [];
|
|
140
|
+
adaptiveToText(content, parts);
|
|
141
|
+
if (parts.length)
|
|
142
|
+
out.push(`[card]\n${parts.filter(Boolean).join("\n")}`);
|
|
143
|
+
}
|
|
144
|
+
else if (ct === "application/vnd.microsoft.teams.card.o365connector") {
|
|
145
|
+
out.push(`[connector card]\n${connectorToText(content).filter(Boolean).join("\n")}`);
|
|
146
|
+
}
|
|
147
|
+
else if (ct === "application/vnd.microsoft.card.hero" || ct === "application/vnd.microsoft.card.thumbnail") {
|
|
148
|
+
out.push(`[card]\n${heroToText(content).filter(Boolean).join("\n")}`);
|
|
149
|
+
}
|
|
150
|
+
else if (ct === "messageReference") {
|
|
151
|
+
const preview = content?.messagePreview ?? "";
|
|
152
|
+
const who = content?.messageSender?.user?.displayName ?? content?.messageSender?.application?.displayName ?? "";
|
|
153
|
+
out.push(`[quoting ${who}: ${String(preview).slice(0, 200)}]`);
|
|
154
|
+
}
|
|
155
|
+
else if (ct === "reference") {
|
|
156
|
+
out.push(`[file: ${a.name ?? "attachment"}${a.contentUrl ? ` ${a.contentUrl}` : ""}]`);
|
|
157
|
+
}
|
|
158
|
+
else if (a.name || a.contentUrl) {
|
|
159
|
+
out.push(`[attachment: ${a.name ?? ct}${a.contentUrl ? ` ${a.contentUrl}` : ""}]`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
function sender(from) {
|
|
165
|
+
if (!from)
|
|
166
|
+
return "system";
|
|
167
|
+
if (from.user)
|
|
168
|
+
return from.user.displayName ?? from.user.id ?? "unknown user";
|
|
169
|
+
if (from.application)
|
|
170
|
+
return `${from.application.displayName ?? "app"} (bot)`;
|
|
171
|
+
if (from.device)
|
|
172
|
+
return `${from.device.displayName ?? "device"} (device)`;
|
|
173
|
+
return "unknown";
|
|
174
|
+
}
|
|
175
|
+
export function truncate(text, max) {
|
|
176
|
+
return text.length > max ? `${text.slice(0, max)}\n…[truncated ${text.length - max} chars]` : text;
|
|
177
|
+
}
|
|
178
|
+
export function formatTeamsMessage(m, maxChars = 4000) {
|
|
179
|
+
const head = [`[${m.createdDateTime}] ${sender(m.from)}`, `id=${m.id}`];
|
|
180
|
+
if (m.replyToId)
|
|
181
|
+
head.push(`replyTo=${m.replyToId}`);
|
|
182
|
+
if (m.lastEditedDateTime)
|
|
183
|
+
head.push("edited");
|
|
184
|
+
if (m.deletedDateTime)
|
|
185
|
+
return `${head.join(" · ")}\n(deleted)`;
|
|
186
|
+
const parts = [];
|
|
187
|
+
if (m.subject)
|
|
188
|
+
parts.push(`Subject: ${m.subject}`);
|
|
189
|
+
if (m.messageType === "systemEventMessage") {
|
|
190
|
+
parts.push(`(system event: ${m.eventDetail?.["@odata.type"]?.replace("#microsoft.graph.", "") ?? "unknown"})`);
|
|
191
|
+
}
|
|
192
|
+
const body = m.body?.contentType === "html" ? htmlToText(m.body?.content) : String(m.body?.content ?? "").trim();
|
|
193
|
+
if (body)
|
|
194
|
+
parts.push(body);
|
|
195
|
+
parts.push(...attachmentsToText(m.attachments));
|
|
196
|
+
const reactions = (m.reactions ?? []).map((r) => r.displayName ?? r.reactionType).filter(Boolean);
|
|
197
|
+
if (reactions.length)
|
|
198
|
+
parts.push(`(reactions: ${reactions.join(" ")})`);
|
|
199
|
+
return `${head.join(" · ")}\n${truncate(parts.join("\n") || "(empty)", maxChars)}`;
|
|
200
|
+
}
|
|
201
|
+
function addr(r) {
|
|
202
|
+
const e = r?.emailAddress;
|
|
203
|
+
if (!e)
|
|
204
|
+
return "";
|
|
205
|
+
return e.name && e.name !== e.address ? `${e.name} <${e.address}>` : (e.address ?? "");
|
|
206
|
+
}
|
|
207
|
+
export function formatMailSummary(m) {
|
|
208
|
+
const flags = [m.isRead === false ? "UNREAD" : "", m.hasAttachments ? "📎" : "", m.importance === "high" ? "!" : ""]
|
|
209
|
+
.filter(Boolean)
|
|
210
|
+
.join(" ");
|
|
211
|
+
return [
|
|
212
|
+
`[${m.receivedDateTime}] ${flags ? `${flags} ` : ""}${m.subject ?? "(no subject)"}`,
|
|
213
|
+
` from: ${addr(m.from)}`,
|
|
214
|
+
` id: ${m.id}`,
|
|
215
|
+
m.bodyPreview ? ` ${String(m.bodyPreview).replace(/\s+/g, " ").slice(0, 200)}` : "",
|
|
216
|
+
]
|
|
217
|
+
.filter(Boolean)
|
|
218
|
+
.join("\n");
|
|
219
|
+
}
|
|
220
|
+
export function formatMailFull(m, maxChars) {
|
|
221
|
+
const body = m.body?.contentType === "html" ? htmlToText(m.body?.content) : String(m.body?.content ?? "");
|
|
222
|
+
const lines = [
|
|
223
|
+
`Subject: ${m.subject ?? "(no subject)"}`,
|
|
224
|
+
`From: ${addr(m.from)}`,
|
|
225
|
+
`To: ${(m.toRecipients ?? []).map(addr).join(", ")}`,
|
|
226
|
+
m.ccRecipients?.length ? `Cc: ${m.ccRecipients.map(addr).join(", ")}` : "",
|
|
227
|
+
`Received: ${m.receivedDateTime}`,
|
|
228
|
+
`Id: ${m.id}`,
|
|
229
|
+
m.conversationId ? `ConversationId: ${m.conversationId}` : "",
|
|
230
|
+
m.webLink ? `Link: ${m.webLink}` : "",
|
|
231
|
+
].filter(Boolean);
|
|
232
|
+
if (m.attachments?.length) {
|
|
233
|
+
lines.push(`Attachments: ${m.attachments.map((a) => `${a.name} (${a.contentType}, ${a.size} bytes)`).join("; ")}`);
|
|
234
|
+
}
|
|
235
|
+
return `${lines.join("\n")}\n\n${truncate(body, maxChars)}`;
|
|
236
|
+
}
|