@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 +56 -0
- package/src/constants.ts +160 -0
- package/src/document-upload.ts +97 -0
- package/src/graph-client.ts +85 -0
- package/src/server.ts +106 -0
- package/src/sharepoint.ts +874 -0
- package/tsconfig.json +21 -0
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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
}
|