@habibakhaledm/sharepoint-document-upload-mcp 1.1.3

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/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@habibakhaledm/sharepoint-document-upload-mcp",
3
+ "version": "1.1.3",
4
+ "description": "A MCP server for uploading document to Sharepoint",
5
+ "main": "src/server.ts",
6
+ "bin": {
7
+ "sharepoint-document-upload-mcp": "dist/server.js"
8
+ },
9
+ "files": [
10
+ "bin/**/*.mjs",
11
+ "src/**/*.ts",
12
+ "tsconfig.json"
13
+ ],
14
+ "scripts": {
15
+ "prepublishOnly": "npm run build",
16
+ "build": "tsc",
17
+ "start": "tsx src/server.ts",
18
+ "dev": "tsx src/server.ts",
19
+ "test:mcp": "tsx scripts/run-mcp-upload-test.ts",
20
+ "test": "echo \"Error: no test specified\" && exit 1"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/HabibaKhaledMohammed/Sharepoint-Document-Upload-mcp.git"
25
+ },
26
+ "keywords": [
27
+ "mcp",
28
+ "modelcontextprotocol",
29
+ "sharepoint",
30
+ "microsoft-graph",
31
+ "upload",
32
+ "azure"
33
+ ],
34
+ "author": "Habiba Khaled Mohammed <habibakhaled606@gmail.com>",
35
+ "license": "ISC",
36
+ "type": "module",
37
+ "engines": {
38
+ "node": ">=18.18.0"
39
+ },
40
+ "dependencies": {
41
+ "@azure/identity": "^4.13.1",
42
+ "@azure/identity-cache-persistence": "^1.2.0",
43
+ "@modelcontextprotocol/sdk": "^1.29.0",
44
+ "dotenv": "^17.3.1",
45
+ "tsx": "^4.19.2",
46
+ "zod": "^4.3.6"
47
+ },
48
+ "devDependencies": {
49
+ "@babel/core": "^7.29.0",
50
+ "@types/node": "^22.19.17",
51
+ "babel-loader": "^10.1.1",
52
+ "typescript": "^5.7.2",
53
+ "webpack": "^5.105.4",
54
+ "webpack-cli": "^7.0.2"
55
+ }
56
+ }
@@ -0,0 +1,160 @@
1
+ /** Central configuration literals (no secrets). */
2
+
3
+ /* Microsoft Graph */
4
+ export const GRAPH_V1_BASE_URL = "https://graph.microsoft.com/v1.0" as const;
5
+ export const GRAPH_BETA_BASE_URL = "https://graph.microsoft.com/beta" as const;
6
+ export const MICROSOFT_AUTH_MODE="interactive" as const;
7
+ export const MICROSOFT_REDIRECT_URI="http://localhost" as const;
8
+ /** Delegated scopes requested for interactive SharePoint upload. */
9
+ export const GRAPH_DELEGATED_SCOPES = [
10
+ "https://graph.microsoft.com/Sites.Read.All",
11
+ "https://graph.microsoft.com/Files.ReadWrite.All",
12
+ ] as const;
13
+
14
+ /* Microsoft Entra (interactive public client) */
15
+ export const ENTRA_DEFAULT_REDIRECT_URI =
16
+ "https://login.microsoftonline.com/common/oauth2/nativeclient" as const;
17
+
18
+ /** Used when MICROSOFT_TENANT_ID is omitted. */
19
+ export const ENTRA_TENANT_FALLBACK_ORGANIZATIONS = "organizations" as const;
20
+
21
+ /**
22
+ * MSAL disk cache folder name under the OS store (e.g. Windows:
23
+ * %LocalAppData%\\.IdentityService\\<name>). Override with MICROSOFT_TOKEN_CACHE_NAME.
24
+ * Set MICROSOFT_DISABLE_TOKEN_CACHE=true to skip persistence (browser every run).
25
+ * If cache init fails without OS encryption, set MICROSOFT_TOKEN_CACHE_UNSAFE_UNENCRYPTED=true.
26
+ */
27
+ export const MICROSOFT_TOKEN_CACHE_DEFAULT_NAME = "document-upload-mcp-graph" as const;
28
+
29
+ /**
30
+ * Account binding file for silent auth (Azure Identity needs this in addition to MSAL token cache).
31
+ * Default directory: ~/.document-upload-mcp (override MICROSOFT_AUTH_RECORD_DIR).
32
+ * Delete `auth-record-*.json` there to force sign-in again.
33
+ */
34
+ export const MICROSOFT_AUTH_RECORD_BASE_DIR = ".document-upload-mcp" as const;
35
+
36
+ /* SharePoint */
37
+ export const SHAREPOINT_DEFAULT_DOCUMENT_LIBRARY_NAME = "Documents" as const;
38
+
39
+ /* HTTP (Graph requests) */
40
+ export const HTTP_CONTENT_TYPE_JSON = "application/json" as const;
41
+ export const HTTP_CONTENT_TYPE_OCTET_STREAM = "application/octet-stream" as const;
42
+
43
+ /* MCP server metadata */
44
+ export const MCP_SERVER_NAME = "sharepoint-document-upload-mcp" as const;
45
+ export const MCP_SERVER_VERSION = "1.0.0" as const;
46
+ export const MCP_SERVER_DESCRIPTION = "A MCP server for uploading document" as const;
47
+
48
+ /* Local document handling */
49
+ export const ALLOWED_UPLOAD_EXTENSIONS = [
50
+ ".txt",
51
+ ".md",
52
+ ".pdf",
53
+ ".docx",
54
+ ] as const;
55
+
56
+ export const LOCAL_UPLOAD_DIR = "uploads" as const;
57
+
58
+ /** Prefix length from SHA-256 hex used in SharePoint upload filenames. */
59
+ export const SHAREPOINT_FILENAME_HASH_PREFIX_LENGTH = 12;
60
+
61
+ /** Used with `crypto.createHash` for document deduplication and filenames. */
62
+ export const DOCUMENT_HASH_ALGORITHM = "sha256" as const;
63
+
64
+ /* MCP tools — names, titles, descriptions, responses */
65
+ export const TOOL_NAME_HELLO_WORLD = "hello_world" as const;
66
+ export const TOOL_NAME_UPLOAD_DOCUMENT = "upload_document" as const;
67
+
68
+ export const TOOL_HELLO_WORLD_TITLE = "Hello World" as const;
69
+ export const TOOL_HELLO_WORLD_DESCRIPTION =
70
+ "This tool will return Hello World" as const;
71
+ export const TOOL_HELLO_WORLD_RESPONSE_TEXT = "Hello World" as const;
72
+
73
+ export const TOOL_UPLOAD_DOCUMENT_TITLE = "Upload a document" as const;
74
+ export const TOOL_UPLOAD_DOCUMENT_DESCRIPTION =
75
+ "Upload a document locally and to SharePoint when configured. Uses interactive Microsoft sign-in with Graph scopes Sites.Read.All and Files.ReadWrite.All only — remove other API permissions on the Entra app for SharePoint-only consent. After the first sign-in, account metadata and tokens are stored on disk (see ~/.document-upload-mcp and OS .IdentityService cache) so later runs usually skip the browser. Set MICROSOFT_DISABLE_TOKEN_CACHE=true only to disable the MSAL file cache." as const;
76
+
77
+ /** Zod field descriptions for `upload_document` input schema */
78
+ export const SCHEMA_DESC_DOCUMENT_FILENAME =
79
+ "Original filename including extension (e.g. report.pdf)" as const;
80
+ export const SCHEMA_DESC_DOCUMENT_CONTENT =
81
+ "File body as UTF-8 text or base64-encoded bytes" as const;
82
+ export const SCHEMA_DESC_MICROSOFT_TENANT_ID =
83
+ "Entra tenant ID (optional; env MICROSOFT_TENANT_ID; omit for organizations / multi-tenant)" as const;
84
+ export const SCHEMA_DESC_MICROSOFT_CLIENT_ID =
85
+ "App registration client ID (required for interactive browser sign-in; env MICROSOFT_CLIENT_ID)" as const;
86
+ export const SCHEMA_DESC_SHAREPOINT_SITE_HOST =
87
+ "SharePoint hostname, e.g. contoso.sharepoint.com (or set SHAREPOINT_SITE_HOST env)" as const;
88
+ export const SCHEMA_DESC_SHAREPOINT_SITE_PATH =
89
+ "Server-relative site path (e.g. /sites/Team). Use empty string for the root site" as const;
90
+ export const SCHEMA_DESC_SHAREPOINT_UPLOAD_BASE_PATH =
91
+ "Folder under the document library (e.g. Incoming/2026). Omit or empty for library root" as const;
92
+
93
+ /* Server messages */
94
+ export const ERR_UNSUPPORTED_FILE_TYPE = "Unsupported file type" as const;
95
+ export const MSG_UPLOAD_SUCCESS_TEMPLATE = (sharePointNote: string) =>
96
+ `Upload successful.${sharePointNote}`;
97
+ /** Appended when Graph returns a browser URL for the uploaded item. */
98
+ export const MSG_SHAREPOINT_DOC_URL_LINE = (url: string) =>
99
+ `\n\nSharePoint document URL:\n${url}` as const;
100
+ /** Opaque Microsoft Graph `driveItem` id (for `/drives/.../items/{id}`). */
101
+ export const MSG_SHAREPOINT_DRIVE_ITEM_ID_LINE = (id: string) =>
102
+ `\n\nMicrosoft Graph drive item id:\n${id}` as const;
103
+ /** SharePoint list row id (often what people mean by "item id" in libraries). */
104
+ export const MSG_SHAREPOINT_LIST_ITEM_ID_LINE = (id: string) =>
105
+ `\n\nSharePoint list item id:\n${id}` as const;
106
+ export const MSG_SHAREPOINT_LIST_ITEM_UNIQUE_ID_LINE = (id: string) =>
107
+ `\n\nSharePoint list item unique id:\n${id}` as const;
108
+ export const SP_RESULT_NOTE_COMPLETED =
109
+ "\n\nSharePoint upload completed (no document URL or ids in API response)." as const;
110
+
111
+ /* SharePoint / Graph runtime errors (user-facing) */
112
+ export const ERR_INTERACTIVE_NO_GRAPH_TOKEN =
113
+ "Interactive sign-in did not return a Microsoft Graph token." as const;
114
+ export const ERR_CONSENT_DECLINED_HINT =
115
+ " Grant only delegated Sites.Read.All and Files.ReadWrite.All on the app registration, then accept consent." as const;
116
+ export const AADSTS65004_SNIPPET = "AADSTS65004" as const;
117
+ export const CONSENT_DECLINED_SNIPPET = "declined to consent" as const;
118
+
119
+ export const ERR_SHAREPOINT_NO_DRIVES = "No drives found on SharePoint site" as const;
120
+
121
+ export const ERR_MICROSOFT_CLIENT_ID_REQUIRED = (redirectUri: string) =>
122
+ `MICROSOFT_CLIENT_ID is required. Use a public Entra client with redirect "${redirectUri}" and delegated Sites.Read.All + Files.ReadWrite.All.`;
123
+
124
+ export const ERR_GRAPH_FOLDER_CHECK = (
125
+ pathSoFar: string,
126
+ status: number,
127
+ body: string
128
+ ) => `Graph folder check ${pathSoFar}: ${status} ${body}`;
129
+
130
+ export const ERR_GRAPH_CREATE_FOLDER = (
131
+ segment: string,
132
+ parentPath: string,
133
+ status: number,
134
+ body: string
135
+ ) =>
136
+ `Create folder "${segment}" under "${parentPath || "root"}": ${status} ${body}`;
137
+
138
+ export const MICROSOFT_GRAPH_CONFLICT_BEHAVIOR_FAIL = "fail" as const;
139
+
140
+ /** Set to `1` to log why `*-my.sharepoint.com/shared` URL construction was skipped. */
141
+ export const DEBUG_SHAREPOINT_MY_URL_ENV = "SHAREPOINT_DEBUG_MY_URL" as const;
142
+
143
+ /* Graph client error prefixes */
144
+ export const ERR_GRAPH_GET = (graphPath: string, status: number, body: string) =>
145
+ `Graph GET ${graphPath}: ${status} ${body}`;
146
+ export const ERR_GRAPH_PUT = (graphPath: string, status: number, body: string) =>
147
+ `Graph PUT ${graphPath}: ${status} ${body}`;
148
+
149
+ /* Integration test script (run-mcp-upload-test) */
150
+ export const MCP_UPLOAD_TEST_CLIENT_NAME = "upload-test" as const;
151
+ export const MCP_UPLOAD_TEST_CLIENT_VERSION = "1.0.0" as const;
152
+ export const MCP_CALL_TIMEOUT_ENV = "MCP_CALL_TIMEOUT_MS" as const;
153
+ export const MCP_CALL_TIMEOUT_DEFAULT_MS = 900_000;
154
+ /** Default fixture under `fixtures/`; override with env MCP_UPLOAD_TEST_FIXTURE. */
155
+ export const FIXTURE_UPLOAD_TEST_FILENAME = "mcp-random-test.md" as const;
156
+ export const MCP_UPLOAD_TEST_FIXTURE_ENV = "MCP_UPLOAD_TEST_FIXTURE" as const;
157
+
158
+ /** Project-relative paths (repo root). */
159
+ export const PROJECT_REL_TSX_CLI = "node_modules/tsx/dist/cli.mjs" as const;
160
+ export const PROJECT_REL_SERVER_ENTRY = "src/server.ts" as const;
@@ -0,0 +1,97 @@
1
+ import {
2
+ ALLOWED_UPLOAD_EXTENSIONS,
3
+ DOCUMENT_HASH_ALGORITHM,
4
+ ERR_UNSUPPORTED_FILE_TYPE,
5
+ LOCAL_UPLOAD_DIR,
6
+ MSG_SHAREPOINT_DOC_URL_LINE,
7
+ MSG_SHAREPOINT_DRIVE_ITEM_ID_LINE,
8
+ MSG_SHAREPOINT_LIST_ITEM_ID_LINE,
9
+ MSG_SHAREPOINT_LIST_ITEM_UNIQUE_ID_LINE,
10
+ SHAREPOINT_FILENAME_HASH_PREFIX_LENGTH,
11
+ SP_RESULT_NOTE_COMPLETED,
12
+ } from "./constants.js";
13
+ import { uploadBufferToSharePointIfConfigured } from "./sharepoint.js";
14
+ import path from "node:path";
15
+ import fs from "node:fs/promises";
16
+ import crypto from "node:crypto";
17
+
18
+ export async function uploadDocument(
19
+ document: {
20
+ filename: string;
21
+ content: string;
22
+ },
23
+ MICROSOFT_TENANT_ID: string,
24
+ MICROSOFT_CLIENT_ID: string,
25
+ SHAREPOINT_SITE_HOST: string,
26
+ SHAREPOINT_SITE_PATH: string,
27
+ SHAREPOINT_UPLOAD_BASE_PATH: string
28
+ ){
29
+ const { filename, content } = document;
30
+
31
+ const ext = path.extname(filename).toLowerCase();
32
+ if (!(ALLOWED_UPLOAD_EXTENSIONS as readonly string[]).includes(ext)) {
33
+ throw new Error(ERR_UNSUPPORTED_FILE_TYPE);
34
+ }
35
+
36
+ const uploadDir = path.resolve(LOCAL_UPLOAD_DIR);
37
+ await fs.mkdir(uploadDir, { recursive: true });
38
+
39
+ const filePath = path.join(uploadDir, filename);
40
+
41
+ const buffer = isBase64(content)
42
+ ? Buffer.from(content, "base64")
43
+ : Buffer.from(content, "utf-8");
44
+
45
+ await fs.writeFile(filePath, buffer);
46
+
47
+ const hash = crypto.createHash(DOCUMENT_HASH_ALGORITHM).update(buffer).digest("hex");
48
+
49
+ const sp = await uploadBufferToSharePointIfConfigured(
50
+ buffer,
51
+ `${hash.slice(0, SHAREPOINT_FILENAME_HASH_PREFIX_LENGTH)}_${filename}`,
52
+ {
53
+ MICROSOFT_TENANT_ID,
54
+ MICROSOFT_CLIENT_ID,
55
+ SHAREPOINT_SITE_HOST,
56
+ SHAREPOINT_SITE_PATH,
57
+ SHAREPOINT_UPLOAD_BASE_PATH,
58
+ }
59
+ );
60
+ const spNote = formatSharePointResultNote(sp);
61
+ return spNote;
62
+
63
+ }
64
+ function formatSharePointResultNote(
65
+ sp: Awaited<ReturnType<typeof uploadBufferToSharePointIfConfigured>>
66
+ ): string {
67
+ if (sp.skipped) {
68
+ return "";
69
+ }
70
+ const parts: string[] = [];
71
+ if (sp.webUrl) {
72
+ parts.push(MSG_SHAREPOINT_DOC_URL_LINE(sp.webUrl));
73
+ }
74
+ if (sp.driveItemId) {
75
+ parts.push(MSG_SHAREPOINT_DRIVE_ITEM_ID_LINE(sp.driveItemId));
76
+ }
77
+ const listItemId = sp.sharepointIds?.listItemId;
78
+ if (listItemId) {
79
+ parts.push(MSG_SHAREPOINT_LIST_ITEM_ID_LINE(listItemId));
80
+ }
81
+ const uniqueId = sp.sharepointIds?.listItemUniqueId;
82
+ if (uniqueId) {
83
+ parts.push(MSG_SHAREPOINT_LIST_ITEM_UNIQUE_ID_LINE(uniqueId));
84
+ }
85
+ if (parts.length > 0) {
86
+ return parts.join("");
87
+ }
88
+ return SP_RESULT_NOTE_COMPLETED;
89
+ }
90
+
91
+ function isBase64(str: string): boolean {
92
+ try {
93
+ return Buffer.from(str, "base64").toString("base64") === str;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
@@ -0,0 +1,85 @@
1
+ /** Minimal Microsoft Graph v1 HTTP helpers (Bearer token). */
2
+
3
+ import {
4
+ ERR_GRAPH_GET,
5
+ ERR_GRAPH_PUT,
6
+ GRAPH_BETA_BASE_URL,
7
+ GRAPH_V1_BASE_URL,
8
+ HTTP_CONTENT_TYPE_OCTET_STREAM,
9
+ } from "./constants.js";
10
+
11
+ export const GRAPH_V1_ROOT = GRAPH_V1_BASE_URL;
12
+ export const GRAPH_BETA_ROOT = GRAPH_BETA_BASE_URL;
13
+
14
+ /** Subset of DriveItem fields used after upload / metadata reads. */
15
+ export type GraphDriveItemSummary = {
16
+ id?: string;
17
+ webUrl?: string;
18
+ sharepointIds?: {
19
+ listId?: string;
20
+ listItemId?: string;
21
+ listItemUniqueId?: string;
22
+ siteId?: string;
23
+ };
24
+ };
25
+
26
+ /** Drive item path: encode each path segment (handles `/`, `&`, spaces). */
27
+ export function encodeDriveRelativePath(relativePath: string): string {
28
+ return relativePath
29
+ .split("/")
30
+ .filter(Boolean)
31
+ .map((s) => encodeURIComponent(s))
32
+ .join("/");
33
+ }
34
+
35
+ export async function graphRequest(
36
+ token: string,
37
+ graphPath: string,
38
+ init?: RequestInit
39
+ ): Promise<Response> {
40
+ const headers = new Headers(init?.headers ?? undefined);
41
+ headers.set("Authorization", `Bearer ${token}`);
42
+ return fetch(`${GRAPH_V1_ROOT}${graphPath}`, { ...init, headers });
43
+ }
44
+
45
+ export async function graphGetJson<T>(token: string, graphPath: string): Promise<T> {
46
+ const res = await graphRequest(token, graphPath);
47
+ if (!res.ok) {
48
+ throw new Error(ERR_GRAPH_GET(graphPath, res.status, await res.text()));
49
+ }
50
+ return res.json() as Promise<T>;
51
+ }
52
+
53
+ export async function graphBetaGetJson<T>(token: string, graphPath: string): Promise<T> {
54
+ const headers = new Headers();
55
+ headers.set("Authorization", `Bearer ${token}`);
56
+ const res = await fetch(`${GRAPH_BETA_ROOT}${graphPath}`, { headers });
57
+ if (!res.ok) {
58
+ throw new Error(ERR_GRAPH_GET(`[beta]${graphPath}`, res.status, await res.text()));
59
+ }
60
+ return res.json() as Promise<T>;
61
+ }
62
+
63
+ export async function graphPutStream(
64
+ token: string,
65
+ graphPath: string,
66
+ body: Buffer
67
+ ): Promise<GraphDriveItemSummary> {
68
+ const res = await graphRequest(token, graphPath, {
69
+ method: "PUT",
70
+ headers: { "Content-Type": HTTP_CONTENT_TYPE_OCTET_STREAM },
71
+ body: new Uint8Array(body),
72
+ });
73
+ if (!res.ok) {
74
+ throw new Error(ERR_GRAPH_PUT(graphPath, res.status, await res.text()));
75
+ }
76
+ const raw = await res.text();
77
+ if (!raw.trim()) {
78
+ return {};
79
+ }
80
+ try {
81
+ return JSON.parse(raw) as GraphDriveItemSummary;
82
+ } catch {
83
+ return {};
84
+ }
85
+ }
package/src/server.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import {
8
+ MCP_SERVER_DESCRIPTION,
9
+ MCP_SERVER_NAME,
10
+ MCP_SERVER_VERSION,
11
+ MSG_UPLOAD_SUCCESS_TEMPLATE,
12
+ SCHEMA_DESC_DOCUMENT_CONTENT,
13
+ SCHEMA_DESC_DOCUMENT_FILENAME,
14
+ SCHEMA_DESC_MICROSOFT_CLIENT_ID,
15
+ SCHEMA_DESC_MICROSOFT_TENANT_ID,
16
+ SCHEMA_DESC_SHAREPOINT_SITE_HOST,
17
+ SCHEMA_DESC_SHAREPOINT_SITE_PATH,
18
+ SCHEMA_DESC_SHAREPOINT_UPLOAD_BASE_PATH,
19
+ TOOL_HELLO_WORLD_RESPONSE_TEXT,
20
+ TOOL_HELLO_WORLD_TITLE,
21
+ TOOL_NAME_HELLO_WORLD,
22
+ TOOL_NAME_UPLOAD_DOCUMENT,
23
+ TOOL_UPLOAD_DOCUMENT_DESCRIPTION,
24
+ TOOL_UPLOAD_DOCUMENT_TITLE,
25
+ TOOL_HELLO_WORLD_DESCRIPTION
26
+ } from "./constants.js";
27
+ import { uploadDocument } from "./document-upload.js";
28
+
29
+ const server = new McpServer({
30
+ name: MCP_SERVER_NAME,
31
+ version: MCP_SERVER_VERSION,
32
+ description: MCP_SERVER_DESCRIPTION,
33
+ });
34
+ server.registerTool(
35
+ TOOL_NAME_HELLO_WORLD,
36
+ {
37
+ title: TOOL_HELLO_WORLD_TITLE,
38
+ description: TOOL_HELLO_WORLD_DESCRIPTION,
39
+ inputSchema: {}
40
+ },
41
+ async () => {
42
+ return { content: [
43
+ {
44
+ type: "text",
45
+ text: TOOL_HELLO_WORLD_RESPONSE_TEXT,
46
+ },
47
+ ],
48
+ };
49
+ }
50
+ );
51
+
52
+ server.registerTool(
53
+ TOOL_NAME_UPLOAD_DOCUMENT,
54
+ {
55
+ title: TOOL_UPLOAD_DOCUMENT_TITLE,
56
+ description: TOOL_UPLOAD_DOCUMENT_DESCRIPTION,
57
+ inputSchema: z.object({
58
+ document: z.object({
59
+ filename: z.string().describe(SCHEMA_DESC_DOCUMENT_FILENAME),
60
+ content: z.string().describe(SCHEMA_DESC_DOCUMENT_CONTENT),
61
+ }),
62
+ MICROSOFT_TENANT_ID: z
63
+ .string()
64
+ .describe(SCHEMA_DESC_MICROSOFT_TENANT_ID),
65
+ MICROSOFT_CLIENT_ID: z
66
+ .string()
67
+ .describe(SCHEMA_DESC_MICROSOFT_CLIENT_ID),
68
+ SHAREPOINT_SITE_HOST: z
69
+ .string()
70
+ .describe(SCHEMA_DESC_SHAREPOINT_SITE_HOST),
71
+ SHAREPOINT_SITE_PATH: z
72
+ .string()
73
+ .describe(SCHEMA_DESC_SHAREPOINT_SITE_PATH),
74
+ SHAREPOINT_UPLOAD_BASE_PATH: z
75
+ .string()
76
+ .describe(SCHEMA_DESC_SHAREPOINT_UPLOAD_BASE_PATH),
77
+ }),
78
+ },
79
+ async ({
80
+ document,
81
+ MICROSOFT_TENANT_ID,
82
+ MICROSOFT_CLIENT_ID,
83
+ SHAREPOINT_SITE_HOST,
84
+ SHAREPOINT_SITE_PATH,
85
+ SHAREPOINT_UPLOAD_BASE_PATH,
86
+ }) => {
87
+ const spNote = await uploadDocument( document,
88
+ MICROSOFT_TENANT_ID,
89
+ MICROSOFT_CLIENT_ID,
90
+ SHAREPOINT_SITE_HOST,
91
+ SHAREPOINT_SITE_PATH,
92
+ SHAREPOINT_UPLOAD_BASE_PATH,
93
+ );
94
+ return {
95
+ content: [
96
+ {
97
+ type: "text" as const,
98
+ text: MSG_UPLOAD_SUCCESS_TEMPLATE(spNote),
99
+ },
100
+ ],
101
+ };
102
+ }
103
+ );
104
+
105
+ const transport = new StdioServerTransport();
106
+ await server.connect(transport);
@@ -0,0 +1,874 @@
1
+ /**
2
+ * SharePoint upload via Microsoft Graph + interactive browser sign-in.
3
+ * See `constants.ts` for Graph scopes, redirect URI, and defaults.
4
+ */
5
+
6
+ import {
7
+ InteractiveBrowserCredential,
8
+ deserializeAuthenticationRecord,
9
+ serializeAuthenticationRecord,
10
+ useIdentityPlugin,
11
+ type AuthenticationRecord,
12
+ } from "@azure/identity";
13
+ import { cachePersistencePlugin } from "@azure/identity-cache-persistence";
14
+ import crypto from "node:crypto";
15
+ import fs from "node:fs/promises";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import {
19
+ AADSTS65004_SNIPPET,
20
+ CONSENT_DECLINED_SNIPPET,
21
+ ENTRA_DEFAULT_REDIRECT_URI,
22
+ ENTRA_TENANT_FALLBACK_ORGANIZATIONS,
23
+ ERR_CONSENT_DECLINED_HINT,
24
+ ERR_GRAPH_CREATE_FOLDER,
25
+ ERR_GRAPH_FOLDER_CHECK,
26
+ ERR_INTERACTIVE_NO_GRAPH_TOKEN,
27
+ ERR_MICROSOFT_CLIENT_ID_REQUIRED,
28
+ ERR_SHAREPOINT_NO_DRIVES,
29
+ DOCUMENT_HASH_ALGORITHM,
30
+ GRAPH_DELEGATED_SCOPES,
31
+ HTTP_CONTENT_TYPE_JSON,
32
+ MCP_SERVER_NAME,
33
+ MICROSOFT_AUTH_RECORD_BASE_DIR,
34
+ DEBUG_SHAREPOINT_MY_URL_ENV,
35
+ MICROSOFT_GRAPH_CONFLICT_BEHAVIOR_FAIL,
36
+ MICROSOFT_TOKEN_CACHE_DEFAULT_NAME,
37
+ SHAREPOINT_DEFAULT_DOCUMENT_LIBRARY_NAME,
38
+ MICROSOFT_REDIRECT_URI,
39
+ } from "./constants.js";
40
+ import {
41
+ encodeDriveRelativePath,
42
+ type GraphDriveItemSummary,
43
+ graphBetaGetJson,
44
+ graphGetJson,
45
+ graphPutStream,
46
+ graphRequest,
47
+ } from "./graph-client.js";
48
+
49
+ /** Re-export for callers that imported redirect from this module. */
50
+ export const DEFAULT_REDIRECT_URI = ENTRA_DEFAULT_REDIRECT_URI;
51
+
52
+ let msalPersistencePluginAttempted = false;
53
+ let msalPersistencePluginRegistered = false;
54
+
55
+ function ensureMsalPersistencePlugin(): boolean {
56
+ if (msalPersistencePluginRegistered) {
57
+ return true;
58
+ }
59
+ if (msalPersistencePluginAttempted) {
60
+ return false;
61
+ }
62
+ msalPersistencePluginAttempted = true;
63
+
64
+ const disabled = process.env.MICROSOFT_DISABLE_TOKEN_CACHE;
65
+ if (disabled === "1" || disabled === "true") {
66
+ return false;
67
+ }
68
+
69
+ try {
70
+ useIdentityPlugin(cachePersistencePlugin);
71
+ msalPersistencePluginRegistered = true;
72
+ return true;
73
+ } catch (e) {
74
+ const msg = e instanceof Error ? e.message : String(e);
75
+ console.error(
76
+ `[${MCP_SERVER_NAME}] MSAL token cache plugin failed (${msg}). Browser sign-in may be required every run.`
77
+ );
78
+ return false;
79
+ }
80
+ }
81
+
82
+ function resolveTokenCachePersistenceOptions():
83
+ | { enabled: true; name: string; unsafeAllowUnencryptedStorage?: boolean }
84
+ | undefined {
85
+ if (!ensureMsalPersistencePlugin()) {
86
+ return undefined;
87
+ }
88
+ const name =
89
+ process.env.MICROSOFT_TOKEN_CACHE_NAME?.trim() ||
90
+ MICROSOFT_TOKEN_CACHE_DEFAULT_NAME;
91
+ const unsafe =
92
+ process.env.MICROSOFT_TOKEN_CACHE_UNSAFE_UNENCRYPTED === "1" ||
93
+ process.env.MICROSOFT_TOKEN_CACHE_UNSAFE_UNENCRYPTED === "true";
94
+ return {
95
+ enabled: true,
96
+ name,
97
+ ...(unsafe ? { unsafeAllowUnencryptedStorage: true as const } : {}),
98
+ };
99
+ }
100
+
101
+ export type SharePointUploadOverrides = {
102
+ MICROSOFT_TENANT_ID?: string;
103
+ MICROSOFT_CLIENT_ID?: string;
104
+ SHAREPOINT_SITE_HOST?: string;
105
+ SHAREPOINT_SITE_PATH?: string;
106
+ SHAREPOINT_UPLOAD_BASE_PATH?: string;
107
+ };
108
+
109
+ type ResolvedSiteConfig = {
110
+ tenantId?: string;
111
+ clientId?: string;
112
+ redirectUri: string;
113
+ siteHost: string;
114
+ sitePath: string;
115
+ folderPath: string;
116
+ driveName: string;
117
+ };
118
+
119
+ function pick(
120
+ override: string | undefined,
121
+ ...envKeys: string[]
122
+ ): string | undefined {
123
+ if (override !== undefined) {
124
+ return override;
125
+ }
126
+ for (const key of envKeys) {
127
+ const v = process.env[key];
128
+ if (v !== undefined && v !== "") {
129
+ return v;
130
+ }
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ function normalizeSitePath(path: string): string {
136
+ const p = path.trim();
137
+ return p.startsWith(":") ? p.slice(1) : p;
138
+ }
139
+
140
+ function resolveSiteConfig(
141
+ overrides?: SharePointUploadOverrides
142
+ ): ResolvedSiteConfig | null {
143
+ const siteHost = pick(overrides?.SHAREPOINT_SITE_HOST, "SHAREPOINT_SITE_HOST");
144
+ if (!siteHost) {
145
+ return null;
146
+ }
147
+
148
+ const rawPath =
149
+ pick(overrides?.SHAREPOINT_SITE_PATH, "SHAREPOINT_SITE_PATH") ?? "";
150
+
151
+ return {
152
+ tenantId: pick(overrides?.MICROSOFT_TENANT_ID, "MICROSOFT_TENANT_ID"),
153
+ clientId: pick(overrides?.MICROSOFT_CLIENT_ID, "MICROSOFT_CLIENT_ID"),
154
+ redirectUri:
155
+ pick(undefined, "MICROSOFT_REDIRECT_URI") ?? MICROSOFT_REDIRECT_URI,
156
+ siteHost,
157
+ sitePath: normalizeSitePath(rawPath),
158
+ folderPath:
159
+ pick(
160
+ overrides?.SHAREPOINT_UPLOAD_BASE_PATH,
161
+ "SHAREPOINT_UPLOAD_BASE_PATH",
162
+ "SHAREPOINT_FOLDER_PATH"
163
+ ) ?? "",
164
+ driveName:
165
+ pick(undefined, "SHAREPOINT_DRIVE_NAME") ??
166
+ SHAREPOINT_DEFAULT_DOCUMENT_LIBRARY_NAME,
167
+ };
168
+ }
169
+
170
+ const GRAPH_SITE_WEB_GUID_RE =
171
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
172
+
173
+ /**
174
+ * `GET /sites/{id}` returns composite ids `hostname,{siteCollectionId},{webId}`.
175
+ * List subpaths like `/lists/{listId}/views` return 400 with the composite form; use the web GUID.
176
+ */
177
+ function siteIdForListScopedRequests(siteId: string): string {
178
+ const segments = siteId.split(",").map((s) => s.trim());
179
+ if (segments.length === 3) {
180
+ const webId = segments[2];
181
+ if (webId && GRAPH_SITE_WEB_GUID_RE.test(webId)) {
182
+ return webId;
183
+ }
184
+ }
185
+ const trimmed = siteId.trim();
186
+ if (GRAPH_SITE_WEB_GUID_RE.test(trimmed)) {
187
+ return trimmed;
188
+ }
189
+ return siteId;
190
+ }
191
+
192
+ function credentialBindingKey(
193
+ tenantId: string,
194
+ clientId: string,
195
+ redirectUri: string
196
+ ): string {
197
+ return `${tenantId}|${clientId}|${redirectUri}`;
198
+ }
199
+
200
+ function authenticationRecordFilePath(bindingKey: string): string {
201
+ const dir =
202
+ process.env.MICROSOFT_AUTH_RECORD_DIR?.trim() ||
203
+ path.join(os.homedir(), MICROSOFT_AUTH_RECORD_BASE_DIR);
204
+ const hash = crypto
205
+ .createHash(DOCUMENT_HASH_ALGORITHM)
206
+ .update(bindingKey)
207
+ .digest("hex")
208
+ .slice(0, 24);
209
+ return path.join(dir, `auth-record-${hash}.json`);
210
+ }
211
+
212
+ async function loadSavedAuthenticationRecord(
213
+ bindingKey: string
214
+ ): Promise<AuthenticationRecord | undefined> {
215
+ try {
216
+ const raw = await fs.readFile(authenticationRecordFilePath(bindingKey), "utf-8");
217
+ return deserializeAuthenticationRecord(raw);
218
+ } catch {
219
+ return undefined;
220
+ }
221
+ }
222
+
223
+ async function saveAuthenticationRecord(
224
+ bindingKey: string,
225
+ record: AuthenticationRecord
226
+ ): Promise<void> {
227
+ const filePath = authenticationRecordFilePath(bindingKey);
228
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
229
+ await fs.writeFile(filePath, serializeAuthenticationRecord(record), "utf-8");
230
+ }
231
+
232
+ function rethrowWithConsentMessageIfNeeded(e: unknown): never {
233
+ const msg = e instanceof Error ? e.message : String(e);
234
+ if (
235
+ msg.includes(AADSTS65004_SNIPPET) ||
236
+ msg.includes(CONSENT_DECLINED_SNIPPET)
237
+ ) {
238
+ throw new Error(`${msg}${ERR_CONSENT_DECLINED_HINT}`);
239
+ }
240
+ throw e;
241
+ }
242
+
243
+ async function acquireGraphToken(
244
+ tenantId: string | undefined,
245
+ clientId: string,
246
+ redirectUri: string
247
+ ): Promise<string> {
248
+ const tenant = tenantId?.trim() || ENTRA_TENANT_FALLBACK_ORGANIZATIONS;
249
+ const bindingKey = credentialBindingKey(tenant, clientId, redirectUri);
250
+ let record = await loadSavedAuthenticationRecord(bindingKey);
251
+ const persistence = resolveTokenCachePersistenceOptions();
252
+
253
+ const credential = new InteractiveBrowserCredential({
254
+ tenantId: tenant,
255
+ clientId,
256
+ redirectUri,
257
+ ...(record ? { authenticationRecord: record } : {}),
258
+ ...(persistence ? { tokenCachePersistenceOptions: persistence } : {}),
259
+ });
260
+
261
+ if (!record) {
262
+ try {
263
+ const interactiveRecord = await credential.authenticate([
264
+ ...GRAPH_DELEGATED_SCOPES,
265
+ ]);
266
+ if (!interactiveRecord) {
267
+ throw new Error(ERR_INTERACTIVE_NO_GRAPH_TOKEN);
268
+ }
269
+ await saveAuthenticationRecord(bindingKey, interactiveRecord);
270
+ } catch (e) {
271
+ rethrowWithConsentMessageIfNeeded(e);
272
+ }
273
+ }
274
+
275
+ try {
276
+ const result = await credential.getToken([...GRAPH_DELEGATED_SCOPES]);
277
+ if (!result?.token) {
278
+ throw new Error(ERR_INTERACTIVE_NO_GRAPH_TOKEN);
279
+ }
280
+ return result.token;
281
+ } catch (e) {
282
+ rethrowWithConsentMessageIfNeeded(e);
283
+ }
284
+ }
285
+
286
+ function sitePathToGraphKey(host: string, sitePath: string): string {
287
+ const seg = sitePath.trim();
288
+ if (seg === "" || seg === "/") {
289
+ return encodeURIComponent(`${host}:/`);
290
+ }
291
+ const normalized = seg.startsWith("/") ? seg : `/${seg}`;
292
+ return encodeURIComponent(`${host}:${normalized}`);
293
+ }
294
+
295
+ async function resolveSiteAndDriveIds(
296
+ token: string,
297
+ host: string,
298
+ sitePath: string,
299
+ driveName: string
300
+ ): Promise<{ siteId: string; driveId: string }> {
301
+ const siteId = (
302
+ await graphGetJson<{ id: string }>(
303
+ token,
304
+ `/sites/${sitePathToGraphKey(host, sitePath)}`
305
+ )
306
+ ).id;
307
+
308
+ const drives = await graphGetJson<{ value: { id: string; name: string }[] }>(
309
+ token,
310
+ `/sites/${encodeURIComponent(siteId)}/drives`
311
+ );
312
+ const drive =
313
+ drives.value.find((d) => d.name === driveName) ?? drives.value[0];
314
+ if (!drive) {
315
+ throw new Error(ERR_SHAREPOINT_NO_DRIVES);
316
+ }
317
+ return { siteId, driveId: drive.id };
318
+ }
319
+
320
+ function mergeDriveItemMeta(
321
+ a: GraphDriveItemSummary,
322
+ b: GraphDriveItemSummary
323
+ ): GraphDriveItemSummary {
324
+ const sharepointIds =
325
+ a.sharepointIds || b.sharepointIds
326
+ ? { ...a.sharepointIds, ...b.sharepointIds }
327
+ : undefined;
328
+ return {
329
+ id: b.id ?? a.id,
330
+ webUrl: b.webUrl ?? a.webUrl,
331
+ sharepointIds,
332
+ };
333
+ }
334
+
335
+ async function getDriveItemBySiteDrivePath(
336
+ token: string,
337
+ siteId: string,
338
+ driveId: string,
339
+ encodedRelativePath: string
340
+ ): Promise<GraphDriveItemSummary> {
341
+ const select = "id,webUrl,sharepointIds";
342
+ const metaPath = `/sites/${encodeURIComponent(siteId)}/drives/${encodeURIComponent(driveId)}/root:/${encodedRelativePath}?$select=${select}`;
343
+ return graphGetJson<GraphDriveItemSummary>(token, metaPath);
344
+ }
345
+
346
+ function sharePointHostnameWithoutScheme(siteHost: string): string {
347
+ return siteHost.replace(/^https?:\/\//i, "").replace(/\/.*$/, "");
348
+ }
349
+
350
+ /** e.g. `integrantincorp` from `integrantincorp.sharepoint.com`. */
351
+ function tenantKeyFromSharePointHost(hostname: string): string | undefined {
352
+ const lower = hostname.toLowerCase();
353
+ const suffix = ".sharepoint.com";
354
+ if (!lower.endsWith(suffix)) {
355
+ return undefined;
356
+ }
357
+ const sub = lower.slice(0, -suffix.length);
358
+ const first = sub.split(".")[0];
359
+ return first || undefined;
360
+ }
361
+
362
+ function serverRelativeItemPathFromGraphWebUrl(itemWebUrl: string): string {
363
+ const u = new URL(itemWebUrl);
364
+ const path = u.pathname.replace(/\/+$/, "") || u.pathname;
365
+ return decodeURIComponent(path);
366
+ }
367
+
368
+ function parentServerRelativePathFromItem(itemPath: string): string {
369
+ const i = itemPath.lastIndexOf("/");
370
+ if (i <= 0) {
371
+ return itemPath;
372
+ }
373
+ return itemPath.slice(0, i);
374
+ }
375
+
376
+ /** Decode URL until stable so `.../Shared%2520Documents` becomes a single-encoded path for `listurl`. */
377
+ function normalizeUrlForSharedListParam(url: string): string {
378
+ let s = url.trim();
379
+ let prev = "";
380
+ while (s !== prev && /%[0-9a-f]{2}/i.test(s)) {
381
+ prev = s;
382
+ try {
383
+ s = decodeURIComponent(s);
384
+ } catch {
385
+ break;
386
+ }
387
+ }
388
+ return s;
389
+ }
390
+
391
+ function buildMySharedQueryString(params: {
392
+ listurl: string;
393
+ viewid?: string;
394
+ id: string;
395
+ parent: string;
396
+ ovuser?: string;
397
+ }): string {
398
+ const listurl = normalizeUrlForSharedListParam(params.listurl);
399
+ const parts: string[] = [`listurl=${encodeURIComponent(listurl)}`];
400
+ if (params.viewid?.trim()) {
401
+ parts.push(`viewid=${encodeURIComponent(params.viewid.trim())}`);
402
+ }
403
+ parts.push(`id=${encodeURIComponent(params.id)}`);
404
+ parts.push(`parent=${encodeURIComponent(params.parent)}`);
405
+ if (params.ovuser?.trim()) {
406
+ parts.push(`ovuser=${encodeURIComponent(params.ovuser.trim())}`);
407
+ }
408
+ return parts.join("&");
409
+ }
410
+
411
+ async function fetchListWebRootUrl(
412
+ token: string,
413
+ siteId: string,
414
+ listId: string
415
+ ): Promise<string | undefined> {
416
+ const sid = siteIdForListScopedRequests(siteId);
417
+ const list = await graphGetJson<{ webUrl?: string }>(
418
+ token,
419
+ `/sites/${encodeURIComponent(sid)}/lists/${encodeURIComponent(
420
+ listId
421
+ )}?$select=webUrl`
422
+ );
423
+ const u = list.webUrl?.trim();
424
+ return u || undefined;
425
+ }
426
+
427
+ function logMySharedUrlDebug(message: string, err?: unknown): void {
428
+ if (process.env[DEBUG_SHAREPOINT_MY_URL_ENV] !== "1") {
429
+ return;
430
+ }
431
+ const detail = err instanceof Error ? err.message : err;
432
+ console.error(`[${MCP_SERVER_NAME}] my/shared URL: ${message}`, detail ?? "");
433
+ }
434
+
435
+ /**
436
+ * Graph list `id` + library webUrl for the document library drive.
437
+ * Prefer site-scoped `/sites/.../drives/.../list` (works for SharePoint libraries).
438
+ */
439
+ async function fetchDriveDocumentLibraryList(
440
+ token: string,
441
+ siteId: string,
442
+ driveId: string
443
+ ): Promise<{ id: string; webUrl?: string } | undefined> {
444
+ const select = "$select=id,webUrl";
445
+ const attempts: { label: string; path: string }[] = [
446
+ {
447
+ label: "site+drive /list",
448
+ path: `/sites/${encodeURIComponent(siteId)}/drives/${encodeURIComponent(
449
+ driveId
450
+ )}/list?${select}`,
451
+ },
452
+ {
453
+ label: "/drives /list",
454
+ path: `/drives/${encodeURIComponent(driveId)}/list?${select}`,
455
+ },
456
+ {
457
+ label: "/drives $expand=list",
458
+ path: `/drives/${encodeURIComponent(driveId)}?$expand=list`,
459
+ },
460
+ ];
461
+
462
+ for (const { label, path } of attempts) {
463
+ try {
464
+ if (path.includes("$expand=list")) {
465
+ const drive = await graphGetJson<{
466
+ list?: { id: string; webUrl?: string };
467
+ }>(token, path);
468
+ if (drive.list?.id) {
469
+ return { id: drive.list.id, webUrl: drive.list.webUrl };
470
+ }
471
+ } else {
472
+ const list = await graphGetJson<{ id: string; webUrl?: string }>(
473
+ token,
474
+ path
475
+ );
476
+ if (list?.id) {
477
+ return { id: list.id, webUrl: list.webUrl };
478
+ }
479
+ }
480
+ } catch (e) {
481
+ logMySharedUrlDebug(`${label} failed`, e);
482
+ }
483
+ }
484
+ logMySharedUrlDebug("no list id from site/drives list endpoints or expand");
485
+ return undefined;
486
+ }
487
+
488
+ type GraphListViewRow = {
489
+ id: string;
490
+ name?: string;
491
+ displayName?: string;
492
+ };
493
+
494
+ function pickPreferredLibraryViewId(views: GraphListViewRow[]): string | undefined {
495
+ if (!views.length) {
496
+ return undefined;
497
+ }
498
+ const preferred =
499
+ views.find((v) => v.name === "All Documents") ??
500
+ views.find((v) => /all\s*documents/i.test(v.name ?? "")) ??
501
+ views.find((v) => /all\s*documents/i.test(v.displayName ?? "")) ??
502
+ views[0];
503
+ return preferred?.id;
504
+ }
505
+
506
+ /**
507
+ * Resolves `viewid` for `*-my.sharepoint.com/shared`. Document libraries often reject
508
+ * `.../lists/{id}/views` on v1; we try `$expand=views`, then v1 `/views`, then beta `/views`.
509
+ */
510
+ async function fetchLibraryViewIdForSharedLink(
511
+ token: string,
512
+ siteId: string,
513
+ listId: string
514
+ ): Promise<string | undefined> {
515
+ const sid = siteIdForListScopedRequests(siteId);
516
+ const listBase = `/sites/${encodeURIComponent(sid)}/lists/${encodeURIComponent(
517
+ listId
518
+ )}`;
519
+
520
+ try {
521
+ const expanded = await graphGetJson<{
522
+ views?: GraphListViewRow[];
523
+ }>(
524
+ token,
525
+ `${listBase}?$expand=views($select=id,name,displayName)&$select=id`
526
+ );
527
+ const vid = pickPreferredLibraryViewId(expanded.views ?? []);
528
+ if (vid) {
529
+ return vid;
530
+ }
531
+ } catch (e) {
532
+ logMySharedUrlDebug("list $expand=views (v1) failed", e);
533
+ }
534
+
535
+ try {
536
+ const data = await graphGetJson<{ value: GraphListViewRow[] }>(
537
+ token,
538
+ `${listBase}/views`
539
+ );
540
+ const vid = pickPreferredLibraryViewId(data.value ?? []);
541
+ if (vid) {
542
+ return vid;
543
+ }
544
+ } catch (e) {
545
+ logMySharedUrlDebug("list /views (v1) failed", e);
546
+ }
547
+
548
+ try {
549
+ const data = await graphBetaGetJson<{ value: GraphListViewRow[] }>(
550
+ token,
551
+ `${listBase}/views`
552
+ );
553
+ const vid = pickPreferredLibraryViewId(data.value ?? []);
554
+ if (vid) {
555
+ return vid;
556
+ }
557
+ } catch (e) {
558
+ logMySharedUrlDebug("list /views (beta) failed", e);
559
+ }
560
+
561
+ return undefined;
562
+ }
563
+
564
+ /**
565
+ * Browser often opens library files via
566
+ * `https://{tenant}-my.sharepoint.com/shared?listurl=&viewid=&id=&parent=` (and optional `ovuser`).
567
+ * Graph `webUrl` alone points at `*.sharepoint.com/...`, which may not match what users copy from the address bar.
568
+ * Omits `xsdata` / `sdata` (session tokens); those are not available server-side.
569
+ */
570
+ async function tryBuildSharePointMySharedDocumentUrl(options: {
571
+ token: string;
572
+ siteId: string;
573
+ driveId: string;
574
+ /** From driveItem.sharepointIds; used only if `/drives/{id}/list` is unavailable. */
575
+ listIdFromSharepointIds?: string;
576
+ itemWebUrl: string;
577
+ sharePointSiteHost: string;
578
+ tenantId?: string;
579
+ }): Promise<string | undefined> {
580
+ const {
581
+ token,
582
+ siteId,
583
+ driveId,
584
+ listIdFromSharepointIds,
585
+ itemWebUrl,
586
+ sharePointSiteHost,
587
+ tenantId,
588
+ } = options;
589
+
590
+ const host = sharePointHostnameWithoutScheme(sharePointSiteHost);
591
+ const tenantKey = tenantKeyFromSharePointHost(host);
592
+ if (!tenantKey) {
593
+ logMySharedUrlDebug("could not derive tenant prefix", host);
594
+ return undefined;
595
+ }
596
+
597
+ const libraryList =
598
+ (await fetchDriveDocumentLibraryList(token, siteId, driveId)) ??
599
+ (listIdFromSharepointIds
600
+ ? { id: listIdFromSharepointIds, webUrl: undefined }
601
+ : undefined);
602
+ if (!libraryList?.id) {
603
+ logMySharedUrlDebug("no library list id (drive list + sharepointIds exhausted)");
604
+ return undefined;
605
+ }
606
+ const listId = libraryList.id;
607
+
608
+ let listWebUrl: string | undefined = libraryList.webUrl?.trim();
609
+ if (!listWebUrl) {
610
+ try {
611
+ listWebUrl = await fetchListWebRootUrl(token, siteId, listId);
612
+ } catch {
613
+ return undefined;
614
+ }
615
+ }
616
+ if (!listWebUrl) {
617
+ logMySharedUrlDebug("no list webUrl", { listId });
618
+ return undefined;
619
+ }
620
+
621
+ const viewId = await fetchLibraryViewIdForSharedLink(token, siteId, listId);
622
+ if (!viewId) {
623
+ logMySharedUrlDebug(
624
+ "no view id discovered; continuing without viewid query param",
625
+ { listId }
626
+ );
627
+ }
628
+
629
+ let itemPath: string;
630
+ try {
631
+ itemPath = serverRelativeItemPathFromGraphWebUrl(itemWebUrl);
632
+ if (!itemPath.startsWith("/")) {
633
+ logMySharedUrlDebug("item path not server-relative", itemPath);
634
+ return undefined;
635
+ }
636
+ } catch (e) {
637
+ logMySharedUrlDebug("parse item webUrl failed", e);
638
+ return undefined;
639
+ }
640
+
641
+ const parentPath = parentServerRelativePathFromItem(itemPath);
642
+ const base = `https://${tenantKey}-my.sharepoint.com/shared`;
643
+
644
+ let ovuser: string | undefined;
645
+ const tid = tenantId?.trim();
646
+ if (tid) {
647
+ try {
648
+ const me = await graphGetJson<{ userPrincipalName?: string }>(
649
+ token,
650
+ "/me?$select=userPrincipalName"
651
+ );
652
+ const upn = me.userPrincipalName?.trim();
653
+ if (upn) {
654
+ ovuser = `${tid},${upn}`;
655
+ }
656
+ } catch {
657
+ /* ovuser is optional */
658
+ }
659
+ }
660
+
661
+ const q = buildMySharedQueryString({
662
+ listurl: listWebUrl,
663
+ viewid: viewId,
664
+ id: itemPath,
665
+ parent: parentPath,
666
+ ovuser,
667
+ });
668
+ return `${base}?${q}`;
669
+ }
670
+
671
+ /**
672
+ * Creates an org-scoped **view** link via Graph; `link.webUrl` generally opens in the
673
+ * browser even when the raw driveItem `webUrl` does not.
674
+ */
675
+ async function tryCreateOrganizationViewLink(
676
+ token: string,
677
+ siteId: string,
678
+ driveId: string,
679
+ itemId: string
680
+ ): Promise<string | undefined> {
681
+ const path = `/sites/${encodeURIComponent(siteId)}/drives/${encodeURIComponent(
682
+ driveId
683
+ )}/items/${encodeURIComponent(itemId)}/createLink`;
684
+ try {
685
+ const res = await graphRequest(token, path, {
686
+ method: "POST",
687
+ headers: { "Content-Type": HTTP_CONTENT_TYPE_JSON },
688
+ body: JSON.stringify({
689
+ type: "view",
690
+ scope: "organization",
691
+ }),
692
+ });
693
+ if (!res.ok) {
694
+ logMySharedUrlDebug(
695
+ "createLink (organization view) failed",
696
+ `${res.status} ${await res.text()}`
697
+ );
698
+ return undefined;
699
+ }
700
+ const data = (await res.json()) as { link?: { webUrl?: string } };
701
+ const url = data.link?.webUrl?.trim();
702
+ return url || undefined;
703
+ } catch (e) {
704
+ logMySharedUrlDebug("createLink error", e);
705
+ return undefined;
706
+ }
707
+ }
708
+
709
+ async function ensureFolderPath(
710
+ token: string,
711
+ driveId: string,
712
+ folderPath: string
713
+ ): Promise<void> {
714
+ const segments = folderPath.split("/").filter(Boolean);
715
+ if (segments.length === 0) {
716
+ return;
717
+ }
718
+
719
+ let pathSoFar = "";
720
+ for (const segment of segments) {
721
+ pathSoFar = pathSoFar ? `${pathSoFar}/${segment}` : segment;
722
+ const encoded = encodeDriveRelativePath(pathSoFar);
723
+ const itemPath = `/drives/${encodeURIComponent(driveId)}/root:/${encoded}`;
724
+
725
+ const check = await graphRequest(token, itemPath);
726
+ if (check.ok) {
727
+ continue;
728
+ }
729
+ if (check.status !== 404) {
730
+ throw new Error(
731
+ ERR_GRAPH_FOLDER_CHECK(pathSoFar, check.status, await check.text())
732
+ );
733
+ }
734
+
735
+ const parentPath = pathSoFar.includes("/")
736
+ ? pathSoFar.slice(0, pathSoFar.lastIndexOf("/"))
737
+ : "";
738
+ const childrenSegment = parentPath
739
+ ? `root:/${encodeDriveRelativePath(parentPath)}:/children`
740
+ : "root/children";
741
+ const createPath = `/drives/${encodeURIComponent(driveId)}/${childrenSegment}`;
742
+
743
+ const created = await graphRequest(token, createPath, {
744
+ method: "POST",
745
+ headers: { "Content-Type": HTTP_CONTENT_TYPE_JSON },
746
+ body: JSON.stringify({
747
+ name: segment,
748
+ folder: {},
749
+ "@microsoft.graph.conflictBehavior": MICROSOFT_GRAPH_CONFLICT_BEHAVIOR_FAIL,
750
+ }),
751
+ });
752
+
753
+ if (created.status === 409) {
754
+ continue;
755
+ }
756
+ if (!created.ok) {
757
+ throw new Error(
758
+ ERR_GRAPH_CREATE_FOLDER(
759
+ segment,
760
+ parentPath,
761
+ created.status,
762
+ await created.text()
763
+ )
764
+ );
765
+ }
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Uploads when `SHAREPOINT_SITE_HOST` resolves (params and/or env).
771
+ * Skips when the site host is missing.
772
+ */
773
+ export type SharePointUploadSaved = {
774
+ skipped: false;
775
+ webUrl?: string;
776
+ /** Microsoft Graph driveItem id (opaque). */
777
+ driveItemId?: string;
778
+ /** Present for items in SharePoint document libraries. */
779
+ sharepointIds?: GraphDriveItemSummary["sharepointIds"];
780
+ };
781
+
782
+ export async function uploadBufferToSharePointIfConfigured(
783
+ buffer: Buffer,
784
+ filename: string,
785
+ overrides?: SharePointUploadOverrides
786
+ ): Promise<{ skipped: true } | SharePointUploadSaved> {
787
+ const cfg = resolveSiteConfig(overrides);
788
+ if (!cfg) {
789
+ return { skipped: true };
790
+ }
791
+
792
+ if (!cfg.clientId) {
793
+ throw new Error(ERR_MICROSOFT_CLIENT_ID_REQUIRED(ENTRA_DEFAULT_REDIRECT_URI));
794
+ }
795
+
796
+ const host = cfg.siteHost.replace(/^https?:\/\//, "");
797
+ const folder = cfg.folderPath
798
+ .replaceAll("\\", "/")
799
+ .replace(/^\/+|\/+$/g, "");
800
+
801
+ const token = await acquireGraphToken(
802
+ cfg.tenantId,
803
+ cfg.clientId,
804
+ cfg.redirectUri
805
+ );
806
+ const { siteId, driveId } = await resolveSiteAndDriveIds(
807
+ token,
808
+ host,
809
+ cfg.sitePath,
810
+ cfg.driveName
811
+ );
812
+
813
+ await ensureFolderPath(token, driveId, folder);
814
+
815
+ const relativeFile = folder ? `${folder}/${filename}` : filename;
816
+ const encodedFile = encodeDriveRelativePath(relativeFile);
817
+ const uploadPath = `/sites/${encodeURIComponent(siteId)}/drives/${encodeURIComponent(driveId)}/root:/${encodedFile}:/content`;
818
+
819
+ const putItem = await graphPutStream(token, uploadPath, buffer);
820
+ const metaItem = await getDriveItemBySiteDrivePath(
821
+ token,
822
+ siteId,
823
+ driveId,
824
+ encodedFile
825
+ );
826
+ const merged = mergeDriveItemMeta(putItem, metaItem);
827
+
828
+ let webUrl = merged.webUrl;
829
+ const itemId = merged.id;
830
+ if (itemId) {
831
+ const openUrl = await tryCreateOrganizationViewLink(
832
+ token,
833
+ siteId,
834
+ driveId,
835
+ itemId
836
+ );
837
+ if (openUrl) {
838
+ webUrl = openUrl;
839
+ } else if (merged.webUrl) {
840
+ const modern = await tryBuildSharePointMySharedDocumentUrl({
841
+ token,
842
+ siteId,
843
+ driveId,
844
+ listIdFromSharepointIds: merged.sharepointIds?.listId,
845
+ itemWebUrl: merged.webUrl,
846
+ sharePointSiteHost: cfg.siteHost,
847
+ tenantId: cfg.tenantId,
848
+ });
849
+ if (modern) {
850
+ webUrl = modern;
851
+ }
852
+ }
853
+ } else if (merged.webUrl) {
854
+ const modern = await tryBuildSharePointMySharedDocumentUrl({
855
+ token,
856
+ siteId,
857
+ driveId,
858
+ listIdFromSharepointIds: merged.sharepointIds?.listId,
859
+ itemWebUrl: merged.webUrl,
860
+ sharePointSiteHost: cfg.siteHost,
861
+ tenantId: cfg.tenantId,
862
+ });
863
+ if (modern) {
864
+ webUrl = modern;
865
+ }
866
+ }
867
+
868
+ return {
869
+ skipped: false,
870
+ webUrl,
871
+ driveItemId: merged.id,
872
+ sharepointIds: merged.sharepointIds,
873
+ };
874
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "outDir": "dist",
9
+ "rootDir": ".",
10
+ "declaration": true,
11
+ "esModuleInterop": true,
12
+ "resolveJsonModule": true,
13
+ "allowJs": true,
14
+ "checkJs": true,
15
+ "noImplicitAny": true,
16
+ "noImplicitThis": true,
17
+ "noImplicitReturns": true,
18
+ "noImplicitOverride": true,
19
+ },
20
+ "include": ["src/**/*.ts", "src/**/*.js", "scripts/**/*.ts"]
21
+ }