@bold-tech-npm/graph-mcp 0.1.0 → 0.2.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/README.md CHANGED
@@ -22,13 +22,18 @@ tenants/clients.
22
22
  | `GRAPH_SCOPES` | | `User.Read,Mail.Read` | Add `Mail.ReadWrite` to enable draft tools |
23
23
  | `GRAPH_TOKEN_CACHE` | | `~/.bold-tech/graph-mcp/<tenant>/tokens.json` | Per-tenant token cache |
24
24
 
25
- ## Sign in (once per tenant)
25
+ ## Sign in
26
26
 
27
+ **Preferred — in-conversation (no terminal):** the AI client calls the **`connect`** tool, which
28
+ returns a verification URL + code; the user opens it, signs in, and **`auth_status`** confirms.
29
+ Nothing to run in a shell. This is the flow to use inside a plugin.
30
+
31
+ **Alternative — CLI (for scripts/CI):**
27
32
  ```bash
28
33
  GRAPH_CLIENT_ID=… GRAPH_TENANT_ID=… npx -y @bold-tech-npm/graph-mcp login
29
34
  ```
30
35
 
31
- Follow the printed URL + code. Tokens cache per-tenant, so multiple clients coexist on one machine.
36
+ Either way, tokens cache per-tenant, so multiple clients coexist on one machine.
32
37
 
33
38
  ## Use from an MCP client / plugin
34
39
 
@@ -52,6 +57,8 @@ Follow the printed URL + code. Tokens cache per-tenant, so multiple clients coex
52
57
 
53
58
  | Tool | Scope | Description |
54
59
  |---|---|---|
60
+ | `connect` | — | Start/confirm sign-in; returns a verification URL + code (or "connected") |
61
+ | `auth_status` | — | Sign-in status: connected / pending (URL + code) / not_connected |
55
62
  | `whoami` | `User.Read` | The signed-in user (proves the auth chain) |
56
63
  | `inbox_list` | `Mail.Read` | Inbox messages with unread/date filters and thread collapse |
57
64
  | `create_draft` | `Mail.ReadWrite` | Create an email draft (never sends) |
package/dist/auth.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { PublicClientApplication } from "@azure/msal-node";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
+ import { spawn } from "node:child_process";
4
5
  import { getConfig } from "./config.js";
5
6
  function cachePlugin(file) {
6
7
  return {
@@ -44,7 +45,125 @@ export async function getToken() {
44
45
  }
45
46
  const result = await pca.acquireTokenSilent({ account: accounts[0], scopes });
46
47
  if (!result?.accessToken) {
47
- throw new Error("Could not acquire a token silently — sign in again with the `login` command.");
48
+ throw new Error("Could not acquire a token silently — sign in again (call `connect`).");
48
49
  }
49
50
  return result.accessToken;
50
51
  }
52
+ /** Username if a valid cached session exists, else null. */
53
+ export async function currentUser() {
54
+ const { scopes } = getConfig();
55
+ const pca = client();
56
+ const accounts = await pca.getTokenCache().getAllAccounts();
57
+ if (accounts.length === 0)
58
+ return null;
59
+ try {
60
+ const result = await pca.acquireTokenSilent({ account: accounts[0], scopes });
61
+ return result?.account?.username ?? accounts[0].username;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ let pending = null;
68
+ /** The in-flight device-code login, if any (cleared once expired). */
69
+ export function getPending() {
70
+ if (pending && !pending.done && !pending.error && Date.now() > pending.expiresAt)
71
+ pending = null;
72
+ return pending;
73
+ }
74
+ /**
75
+ * Begin a device-code sign-in WITHOUT blocking. Returns as soon as the code is
76
+ * issued; the login then completes in the background (this process stays alive as
77
+ * an MCP server) and caches the token. Poll `currentUser()` / `getPending()` after.
78
+ */
79
+ export async function beginDeviceLogin() {
80
+ const { scopes } = getConfig();
81
+ const pca = client();
82
+ pending = null;
83
+ const promise = pca.acquireTokenByDeviceCode({
84
+ scopes,
85
+ deviceCodeCallback: (r) => {
86
+ pending = {
87
+ verificationUri: r.verificationUri,
88
+ userCode: r.userCode,
89
+ expiresAt: Date.now() + r.expiresIn * 1000,
90
+ done: false,
91
+ };
92
+ },
93
+ });
94
+ promise
95
+ .then(() => {
96
+ if (pending)
97
+ pending.done = true;
98
+ })
99
+ .catch((e) => {
100
+ if (pending)
101
+ pending.error = e instanceof Error ? e.message : String(e);
102
+ });
103
+ const start = Date.now();
104
+ while (!getPending() && Date.now() - start < 15000) {
105
+ await new Promise((resolve) => setTimeout(resolve, 100));
106
+ }
107
+ const issued = getPending();
108
+ if (!issued)
109
+ throw new Error("Device code was not issued (network or config problem).");
110
+ openBrowser(issued.verificationUri);
111
+ return issued;
112
+ }
113
+ /** Best-effort open of a URL in the user's default browser. No-op in headless envs. */
114
+ export function openBrowser(url) {
115
+ try {
116
+ if (process.platform === "win32") {
117
+ spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
118
+ }
119
+ else {
120
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
121
+ spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
122
+ }
123
+ }
124
+ catch {
125
+ /* headless / no browser — the caller still returns the URL + code */
126
+ }
127
+ }
128
+ /** Thrown when there's no session; carries the device-code prompt for the caller. */
129
+ export class AuthRequiredError extends Error {
130
+ verificationUri;
131
+ userCode;
132
+ constructor(verificationUri, userCode) {
133
+ super(`Not signed in. Open ${verificationUri} and enter code ${userCode}.`);
134
+ this.verificationUri = verificationUri;
135
+ this.userCode = userCode;
136
+ this.name = "AuthRequiredError";
137
+ }
138
+ }
139
+ /** Silent token if a session exists, else null (no throw). */
140
+ async function trySilentToken() {
141
+ const { scopes } = getConfig();
142
+ const pca = client();
143
+ const accounts = await pca.getTokenCache().getAllAccounts();
144
+ if (accounts.length === 0)
145
+ return null;
146
+ try {
147
+ const result = await pca.acquireTokenSilent({ account: accounts[0], scopes });
148
+ return result?.accessToken ?? null;
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
154
+ /**
155
+ * Token for a tool call — or auto-start sign-in. If there's no session, begin the
156
+ * device-code flow (auto-opening the browser) and throw AuthRequiredError with the
157
+ * code so the tool can tell the user how to finish. Reuses an in-flight login.
158
+ */
159
+ export async function getTokenOrBegin() {
160
+ const token = await trySilentToken();
161
+ if (token)
162
+ return token;
163
+ const existing = getPending();
164
+ if (existing && !existing.done && !existing.error) {
165
+ throw new AuthRequiredError(existing.verificationUri, existing.userCode);
166
+ }
167
+ const p = await beginDeviceLogin();
168
+ throw new AuthRequiredError(p.verificationUri, p.userCode);
169
+ }
package/dist/graph.js CHANGED
@@ -1,8 +1,8 @@
1
- import { getToken } from "./auth.js";
1
+ import { getTokenOrBegin } from "./auth.js";
2
2
  const BASE = "https://graph.microsoft.com/v1.0";
3
3
  /** Thin authenticated Graph fetch. Returns parsed JSON, or null for 204. */
4
4
  export async function graph(path, init) {
5
- const token = await getToken();
5
+ const token = await getTokenOrBegin();
6
6
  const res = await fetch(`${BASE}${path}`, {
7
7
  ...init,
8
8
  headers: {
package/dist/tools.js CHANGED
@@ -1,12 +1,56 @@
1
1
  import { z } from "zod";
2
2
  import { graph } from "./graph.js";
3
+ import { AuthRequiredError, beginDeviceLogin, currentUser, getPending } from "./auth.js";
3
4
  /** Strip leading Re:/Fw:/Fwd: (and I:/R:/Aw: variants) so replies group with their thread. */
4
5
  const normSubject = (s) => (s || "").replace(/^((re|fw|fwd|r|i|aw)\s*:\s*)+/i, "").trim().toLowerCase();
5
6
  const text = (value) => ({
6
7
  content: [{ type: "text", text: JSON.stringify(value) }],
7
8
  });
9
+ /** In a graph tool's catch: turn a missing session into a friendly auth prompt; rethrow the rest. */
10
+ const authText = (e) => {
11
+ if (e instanceof AuthRequiredError) {
12
+ return text({
13
+ status: "auth_required",
14
+ verificationUri: e.verificationUri,
15
+ userCode: e.userCode,
16
+ message: `Not signed in — I've opened the Microsoft sign-in page. Enter code ${e.userCode} and approve, then retry.`,
17
+ });
18
+ }
19
+ throw e;
20
+ };
8
21
  export function registerTools(server) {
9
- server.tool("whoami", "Return the signed-in Microsoft 365 user. Proves the auth chain.", {}, async () => text(await graph("/me?$select=displayName,userPrincipalName,mail")));
22
+ server.tool("connect", "Start (or confirm) Microsoft 365 sign-in for this mailbox. If already signed in, returns the user. Otherwise opens the sign-in page in the browser and returns the verification URL + code; call `auth_status` afterward to confirm. This is the entire connect flow — no terminal command needed.", {}, async () => {
23
+ const user = await currentUser();
24
+ if (user)
25
+ return text({ status: "connected", user });
26
+ const p = await beginDeviceLogin();
27
+ return text({
28
+ status: "pending",
29
+ verificationUri: p.verificationUri,
30
+ userCode: p.userCode,
31
+ instructions: `A browser should have opened. Enter code ${p.userCode} at ${p.verificationUri} and sign in, then call auth_status.`,
32
+ });
33
+ });
34
+ server.tool("auth_status", "Report Microsoft 365 sign-in status: connected (with the user), pending (with the URL + code still to enter), or not_connected.", {}, async () => {
35
+ const user = await currentUser();
36
+ if (user)
37
+ return text({ status: "connected", user });
38
+ const p = getPending();
39
+ if (p?.error)
40
+ return text({ status: "error", error: p.error });
41
+ if (p && !p.done) {
42
+ return text({ status: "pending", verificationUri: p.verificationUri, userCode: p.userCode });
43
+ }
44
+ return text({ status: "not_connected", hint: "Call connect to start sign-in." });
45
+ });
46
+ server.tool("whoami", "Return the signed-in Microsoft 365 user. Proves the auth chain.", {}, async () => {
47
+ try {
48
+ return text(await graph("/me?$select=displayName,userPrincipalName,mail"));
49
+ }
50
+ catch (e) {
51
+ return authText(e);
52
+ }
53
+ });
10
54
  server.tool("inbox_list", "List Inbox messages, newest first, with optional unread and date-window filters. When collapseThreads is true (default) it returns ONE item per thread — grouping by conversationId OR normalized subject and keeping the latest message — so an overnight burst of long threads becomes a clean, deal-mappable list instead of a confusing pile.", {
11
55
  unreadOnly: z.boolean().optional().describe("Only messages the user has not read yet."),
12
56
  sinceHours: z
@@ -19,64 +63,79 @@ export function registerTools(server) {
19
63
  .describe("Collapse each thread to its latest message. Default true."),
20
64
  top: z.number().optional().describe("Max messages to fetch before filtering. Default 60."),
21
65
  }, async ({ unreadOnly, sinceHours, collapseThreads, top }) => {
22
- const data = await graph(`/me/mailFolders/inbox/messages?$top=${top ?? 60}&$select=subject,from,receivedDateTime,isRead,conversationId,bodyPreview`);
23
- let msgs = data.value ?? [];
24
- if (unreadOnly)
25
- msgs = msgs.filter((m) => m.isRead === false);
26
- if (sinceHours) {
27
- const cutoff = Date.now() - sinceHours * 3600_000;
28
- msgs = msgs.filter((m) => Date.parse(m.receivedDateTime) >= cutoff);
66
+ try {
67
+ const data = await graph(`/me/mailFolders/inbox/messages?$top=${top ?? 60}&$select=subject,from,receivedDateTime,isRead,conversationId,bodyPreview`);
68
+ let msgs = data.value ?? [];
69
+ if (unreadOnly)
70
+ msgs = msgs.filter((m) => m.isRead === false);
71
+ if (sinceHours) {
72
+ const cutoff = Date.now() - sinceHours * 3600_000;
73
+ msgs = msgs.filter((m) => Date.parse(m.receivedDateTime) >= cutoff);
74
+ }
75
+ msgs.sort((a, b) => (a.receivedDateTime < b.receivedDateTime ? 1 : -1));
76
+ if (collapseThreads === false) {
77
+ return text({ total: msgs.length, messages: msgs });
78
+ }
79
+ const byThread = new Map();
80
+ for (const m of msgs) {
81
+ const key = normSubject(m.subject) || m.conversationId || m.id;
82
+ const group = byThread.get(key);
83
+ if (group)
84
+ group.push(m);
85
+ else
86
+ byThread.set(key, [m]);
87
+ }
88
+ const threads = [...byThread.values()].map((group) => {
89
+ group.sort((a, b) => (a.receivedDateTime < b.receivedDateTime ? 1 : -1));
90
+ const latest = group[0];
91
+ return {
92
+ count: group.length,
93
+ from: latest.from?.emailAddress?.address ?? null,
94
+ subject: latest.subject,
95
+ receivedDateTime: latest.receivedDateTime,
96
+ preview: latest.bodyPreview,
97
+ messageId: latest.id,
98
+ };
99
+ });
100
+ return text({ totalMessages: msgs.length, threads: threads.length, items: threads });
29
101
  }
30
- msgs.sort((a, b) => (a.receivedDateTime < b.receivedDateTime ? 1 : -1));
31
- if (collapseThreads === false) {
32
- return text({ total: msgs.length, messages: msgs });
102
+ catch (e) {
103
+ return authText(e);
33
104
  }
34
- const byThread = new Map();
35
- for (const m of msgs) {
36
- const key = normSubject(m.subject) || m.conversationId || m.id;
37
- const group = byThread.get(key);
38
- if (group)
39
- group.push(m);
40
- else
41
- byThread.set(key, [m]);
42
- }
43
- const threads = [...byThread.values()].map((group) => {
44
- group.sort((a, b) => (a.receivedDateTime < b.receivedDateTime ? 1 : -1));
45
- const latest = group[0];
46
- return {
47
- count: group.length,
48
- from: latest.from?.emailAddress?.address ?? null,
49
- subject: latest.subject,
50
- receivedDateTime: latest.receivedDateTime,
51
- preview: latest.bodyPreview,
52
- messageId: latest.id,
53
- };
54
- });
55
- return text({ totalMessages: msgs.length, threads: threads.length, items: threads });
56
105
  });
57
106
  server.tool("create_draft", "Create a new email draft in the signed-in mailbox. Requires Mail.ReadWrite. NEVER sends.", {
58
107
  to: z.array(z.string()).describe("Recipient email addresses."),
59
108
  subject: z.string(),
60
109
  body: z.string().describe("HTML or plain-text body."),
61
110
  }, async ({ to, subject, body }) => {
62
- const draft = await graph("/me/messages", {
63
- method: "POST",
64
- body: JSON.stringify({
65
- subject,
66
- body: { contentType: "HTML", content: body },
67
- toRecipients: to.map((address) => ({ emailAddress: { address } })),
68
- }),
69
- });
70
- return text({ id: draft.id, webLink: draft.webLink });
111
+ try {
112
+ const draft = await graph("/me/messages", {
113
+ method: "POST",
114
+ body: JSON.stringify({
115
+ subject,
116
+ body: { contentType: "HTML", content: body },
117
+ toRecipients: to.map((address) => ({ emailAddress: { address } })),
118
+ }),
119
+ });
120
+ return text({ id: draft.id, webLink: draft.webLink });
121
+ }
122
+ catch (e) {
123
+ return authText(e);
124
+ }
71
125
  });
72
126
  server.tool("create_reply_draft", "Create a reply DRAFT to an existing message (keeps the thread). Requires Mail.ReadWrite. NEVER sends.", {
73
127
  messageId: z.string().describe("The id of the message to reply to (from inbox_list)."),
74
128
  comment: z.string().describe("The reply body to prepend above the quoted thread."),
75
129
  }, async ({ messageId, comment }) => {
76
- const draft = await graph(`/me/messages/${messageId}/createReply`, {
77
- method: "POST",
78
- body: JSON.stringify({ comment }),
79
- });
80
- return text({ id: draft.id, webLink: draft.webLink });
130
+ try {
131
+ const draft = await graph(`/me/messages/${messageId}/createReply`, {
132
+ method: "POST",
133
+ body: JSON.stringify({ comment }),
134
+ });
135
+ return text({ id: draft.id, webLink: draft.webLink });
136
+ }
137
+ catch (e) {
138
+ return authText(e);
139
+ }
81
140
  });
82
141
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bold-tech-npm/graph-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Microsoft Graph MCP server with device-code auth and smart inbox reads (unread + date filter, thread collapse by conversation or normalized subject). By Bold Tech.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,14 @@
27
27
  ],
28
28
  "license": "MIT",
29
29
  "author": "Bold Tech",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/Mitchel-BT/graph-mcp.git"
33
+ },
34
+ "homepage": "https://github.com/Mitchel-BT/graph-mcp#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/Mitchel-BT/graph-mcp/issues"
37
+ },
30
38
  "publishConfig": {
31
39
  "access": "public"
32
40
  },