feat(sharepoint): validate against Graph API and add delete/update/download tools (#5369)

* feat(sharepoint): validate against Graph API and add delete/update/download tools

- Fix bugs found validating existing tools against live Graph docs: malformed
  nested $expand syntax in get_list, reversed site-resolution precedence in
  create_list, unsanitized field fallback in add_list_items, missing URL
  encoding in read_page, and an incorrect root/serverRelativeUrl field shape
- Fix V1 block gaps vs V2: upload_file required flag, missing required on
  pageName/listDisplayName, wrong site-picker mimeType, dead subBlock refs
- Add 8 new tools within already-granted scopes for CRUD completion:
  delete_list_item, get_list_item, delete_page, update_page, publish_page,
  download_file, delete_file, get_drive_item
- Add opt-in stripAuthOnRedirect option to secureFetchWithPinnedIP so the
  SharePoint file-download route doesn't resend the bearer token to the
  preauthenticated redirect target (default off, no behavior change elsewhere)
- Dedupe read-only list-item field sanitization into a shared utils helper

* fix(sharepoint): encode siteId consistently and fix create_page precedence

- URL-encode siteId/groupId everywhere it's interpolated into a Graph
  request path (Graph site IDs like "host,guid,guid" contain commas that
  must be encoded) - addresses Cursor Bugbot findings on update_page,
  delete_page, publish_page, and applies the same fix consistently across
  every other sharepoint tool for consistency
- Fix create_page's site-resolution precedence (was siteSelector before
  siteId, inconsistent with every sibling tool)

* fix(sharepoint): escape HTML in page content and stop fallback from throwing

- create_page/update_page only escaped quotes when building innerHtml;
  add a shared escapeHtml() helper that also escapes &, <, > so page
  content with angle brackets/ampersands doesn't corrupt the canvas layout
- add_list_items' fields fallback (sanitized re-derivation when Graph's
  response omits fields) could throw on a malformed fallback input after
  the item was already created successfully; catch and fall back to
  undefined instead of failing an already-successful response

* fix(sharepoint): scope columnDefinitions->pageContent mapping to create_list

Both V1 and V2 tools.config.params mapped columnDefinitions onto pageContent
whenever columnDefinitions was truthy, with no operation check. Stale
list-column JSON left in block state after switching to create_page/
update_page would silently replace the user's intended page text. Gate
the mapping on operation === create_list (V1) / sharepoint_create_list (V2).

* fix(sharepoint): cap download-file content fetch at MAX_FILE_SIZE

Greptile flagged the content fetch as unbounded - a large file would
buffer entirely in memory before base64-encoding into the JSON response.
Pass maxResponseBytes: MAX_FILE_SIZE (100MB, same constant used by the
upload path) to secureFetchWithPinnedIP so oversized files reject early
with a clear PayloadSizeLimitError instead of exhausting memory.

* fix(sharepoint): close V1 block input/output gaps and encode upload URLs

Final validation pass (4 parallel audit agents against live Graph docs)
surfaced remaining gaps:

- apps/sim/app/api/tools/sharepoint/upload/route.ts: siteId/driveId were
  not encodeURIComponent-ed in the Graph upload URL, unlike every other
  sharepoint route/tool - same encoding-gap class fixed everywhere else
- read_page.ts: transformResponse recomputed siteId with a raw `||` in
  3 spots instead of the optionalTrim(...) || optionalTrim(...) || 'root'
  precedence used by request.url and every sibling tool
- SharepointBlock (V1, legacy/hidden-from-toolbar but still executable
  for existing saved workflows): listItemFields and listItemId were
  missing `required` for update_list despite the tool requiring them;
  maxPages/groupId/includeColumns/includeItems/nextPageUrl subBlocks
  were entirely absent, making those tool params unreachable from the
  UI; block-level outputs were missing site/pages/content/totalPages/
  nextPageUrl/lists/skippedFiles/skippedCount/errors even though the
  underlying tools return them - V2 already covered all of these
This commit is contained in:
Waleed
2026-07-02 10:46:35 -07:00
committed by GitHub
parent acece910b4
commit 070f554047
24 changed files with 1582 additions and 273 deletions
@@ -0,0 +1,178 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { sharepointDownloadFileContract } from '@/lib/api/contracts/tools/microsoft'
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
export const dynamic = 'force-dynamic'
/** Microsoft Graph API error response structure */
interface GraphApiError {
error?: {
code?: string
message?: string
}
}
/** Microsoft Graph API drive item metadata response */
interface DriveItemMetadata {
id?: string
name?: string
folder?: Record<string, unknown>
file?: {
mimeType?: string
}
}
const logger = createLogger('SharepointDownloadFileAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized SharePoint download attempt: ${authResult.error}`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
const parsed = await parseRequest(sharepointDownloadFileContract, request, {})
if (!parsed.success) return parsed.response
const { accessToken, driveId, itemId, fileName } = parsed.data.body
const authHeader = `Bearer ${accessToken}`
logger.info(`[${requestId}] Getting file metadata from SharePoint`, { driveId, itemId })
const metadataUrl = `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}`
const metadataUrlValidation = await validateUrlWithDNS(metadataUrl, 'metadataUrl')
if (!metadataUrlValidation.isValid) {
return NextResponse.json(
{ success: false, error: metadataUrlValidation.error },
{ status: 400 }
)
}
const metadataResponse = await secureFetchWithPinnedIP(
metadataUrl,
metadataUrlValidation.resolvedIP!,
{
headers: { Authorization: authHeader },
}
)
if (!metadataResponse.ok) {
const errorDetails = (await metadataResponse.json().catch(() => ({}))) as GraphApiError
logger.error(`[${requestId}] Failed to get file metadata`, {
status: metadataResponse.status,
error: errorDetails,
})
return NextResponse.json(
{ success: false, error: errorDetails.error?.message || 'Failed to get file metadata' },
{ status: 400 }
)
}
const metadata = (await metadataResponse.json()) as DriveItemMetadata
if (metadata.folder && !metadata.file) {
logger.error(`[${requestId}] Attempted to download a folder`, {
itemId: metadata.id,
itemName: metadata.name,
})
return NextResponse.json(
{
success: false,
error: `Cannot download folder "${metadata.name}". Please select a file instead.`,
},
{ status: 400 }
)
}
const mimeType = metadata.file?.mimeType || 'application/octet-stream'
logger.info(`[${requestId}] Downloading file from SharePoint`, { driveId, itemId, mimeType })
const downloadUrl = `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}/content`
const downloadUrlValidation = await validateUrlWithDNS(downloadUrl, 'downloadUrl')
if (!downloadUrlValidation.isValid) {
return NextResponse.json(
{ success: false, error: downloadUrlValidation.error },
{ status: 400 }
)
}
const downloadResponse = await secureFetchWithPinnedIP(
downloadUrl,
downloadUrlValidation.resolvedIP!,
{
headers: { Authorization: authHeader },
// The content endpoint 302s to a preauthenticated URL on a different origin that needs no auth.
stripAuthOnRedirect: true,
maxResponseBytes: MAX_FILE_SIZE,
}
)
if (!downloadResponse.ok) {
const downloadError = (await downloadResponse.json().catch(() => ({}))) as GraphApiError
logger.error(`[${requestId}] Failed to download file`, {
status: downloadResponse.status,
error: downloadError,
})
return NextResponse.json(
{ success: false, error: downloadError.error?.message || 'Failed to download file' },
{ status: 400 }
)
}
const arrayBuffer = await downloadResponse.arrayBuffer()
const fileBuffer = Buffer.from(arrayBuffer)
const resolvedName = fileName || metadata.name || 'download'
logger.info(`[${requestId}] File downloaded successfully`, {
driveId,
itemId,
name: resolvedName,
size: fileBuffer.length,
mimeType,
})
const base64Data = fileBuffer.toString('base64')
return NextResponse.json({
success: true,
output: {
file: {
name: resolvedName,
mimeType,
data: base64Data,
size: fileBuffer.length,
},
},
})
} catch (error) {
logger.error(`[${requestId}] Error downloading SharePoint file:`, error)
return NextResponse.json(
{
success: false,
error: getErrorMessage(error, 'Unknown error occurred'),
},
{ status: 500 }
)
}
})
@@ -135,8 +135,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
.join('/')
const uploadUrl = driveId
? `https://graph.microsoft.com/v1.0/drives/${driveId}/root:${encodedPath}:/content`
: `https://graph.microsoft.com/v1.0/sites/${siteId}/drive/root:${encodedPath}:/content`
? `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/root:${encodedPath}:/content`
: `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/drive/root:${encodedPath}:/content`
logger.info(`[${requestId}] Uploading to: ${uploadUrl}`)
+352 -45
View File
@@ -30,12 +30,20 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
options: [
{ label: 'Create Page', id: 'create_page' },
{ label: 'Read Page', id: 'read_page' },
{ label: 'Update Page', id: 'update_page' },
{ label: 'Publish Page', id: 'publish_page' },
{ label: 'Delete Page', id: 'delete_page' },
{ label: 'List Sites', id: 'list_sites' },
{ label: 'Create List', id: 'create_list' },
{ label: 'Read List', id: 'read_list' },
{ label: 'Update List', id: 'update_list' },
{ label: 'Add List Items', id: 'add_list_items' },
{ label: 'Get List Item', id: 'get_list_item' },
{ label: 'Delete List Item', id: 'delete_list_item' },
{ label: 'Upload File', id: 'upload_file' },
{ label: 'Download File', id: 'download_file' },
{ label: 'Get Drive Item', id: 'get_drive_item' },
{ label: 'Delete File', id: 'delete_file' },
],
},
{
@@ -65,7 +73,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
serviceId: 'sharepoint',
selectorKey: 'sharepoint.sites',
requiredScopes: getScopesForService('sharepoint'),
mimeType: 'application/vnd.microsoft.graph.folder',
mimeType: 'application/vnd.microsoft.graph.site',
placeholder: 'Select a site',
dependsOn: ['credential'],
mode: 'basic',
@@ -74,11 +82,16 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
value: [
'create_page',
'read_page',
'update_page',
'publish_page',
'delete_page',
'list_sites',
'create_list',
'read_list',
'update_list',
'add_list_items',
'get_list_item',
'delete_list_item',
'upload_file',
],
},
@@ -90,14 +103,37 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
type: 'short-input',
placeholder: 'Name of the page',
condition: { field: 'operation', value: ['create_page', 'read_page'] },
required: { field: 'operation', value: 'create_page' },
},
{
id: 'pageTitle',
title: 'Page Title',
type: 'short-input',
placeholder: 'Optional title (defaults to page name)',
condition: { field: 'operation', value: ['create_page', 'update_page'] },
mode: 'advanced',
},
{
id: 'pageContent',
title: 'Page Content',
type: 'long-input',
placeholder: 'Optional text content for the page',
condition: { field: 'operation', value: ['create_page', 'update_page'] },
mode: 'advanced',
},
{
id: 'pageId',
title: 'Page ID',
type: 'short-input',
placeholder: 'Page ID (alternative to page name)',
condition: { field: 'operation', value: 'read_page' },
placeholder: 'Page ID',
condition: {
field: 'operation',
value: ['read_page', 'update_page', 'publish_page', 'delete_page'],
},
required: { field: 'operation', value: ['update_page', 'publish_page', 'delete_page'] },
mode: 'advanced',
},
@@ -111,7 +147,14 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
placeholder: 'Select a list',
dependsOn: ['credential', 'siteSelector'],
mode: 'basic',
condition: { field: 'operation', value: ['read_list', 'update_list', 'add_list_items'] },
condition: {
field: 'operation',
value: ['read_list', 'update_list', 'add_list_items', 'get_list_item', 'delete_list_item'],
},
required: {
field: 'operation',
value: ['update_list', 'add_list_items', 'get_list_item', 'delete_list_item'],
},
},
{
id: 'listId',
@@ -120,7 +163,14 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
canonicalParamId: 'listId',
placeholder: 'Enter list ID (GUID). Required for Update; optional for Read.',
mode: 'advanced',
condition: { field: 'operation', value: ['read_list', 'update_list', 'add_list_items'] },
condition: {
field: 'operation',
value: ['read_list', 'update_list', 'add_list_items', 'get_list_item', 'delete_list_item'],
},
required: {
field: 'operation',
value: ['update_list', 'add_list_items', 'get_list_item', 'delete_list_item'],
},
},
{
@@ -129,7 +179,11 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
type: 'short-input',
placeholder: 'Enter item ID',
canonicalParamId: 'itemId',
condition: { field: 'operation', value: ['update_list'] },
condition: {
field: 'operation',
value: ['update_list', 'get_list_item', 'delete_list_item'],
},
required: { field: 'operation', value: ['update_list', 'get_list_item', 'delete_list_item'] },
},
{
@@ -138,6 +192,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
type: 'short-input',
placeholder: 'Name of the list',
condition: { field: 'operation', value: 'create_list' },
required: { field: 'operation', value: 'create_list' },
},
{
@@ -270,11 +325,16 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
value: [
'create_page',
'read_page',
'update_page',
'publish_page',
'delete_page',
'list_sites',
'create_list',
'read_list',
'update_list',
'add_list_items',
'get_list_item',
'delete_list_item',
'upload_file',
],
},
@@ -288,6 +348,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
'Enter list item fields as JSON (e.g., {"Title": "My Item", "Status": "Active"})',
canonicalParamId: 'listItemFields',
condition: { field: 'operation', value: ['update_list', 'add_list_items'] },
required: { field: 'operation', value: ['update_list', 'add_list_items'] },
wandConfig: {
enabled: true,
prompt: `Generate a JSON object for SharePoint list item fields based on the user's description.
@@ -330,16 +391,81 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
},
},
// Upload File operation fields
{
id: 'maxPages',
title: 'Max Pages',
type: 'short-input',
placeholder: 'Default 10, maximum 50',
condition: { field: 'operation', value: 'read_page' },
mode: 'advanced',
},
{
id: 'groupId',
title: 'Group ID',
type: 'short-input',
placeholder: 'Optional Microsoft 365 group ID',
condition: { field: 'operation', value: 'list_sites' },
mode: 'advanced',
},
{
id: 'includeColumns',
title: 'Include Columns',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
condition: { field: 'operation', value: 'read_list' },
mode: 'advanced',
},
{
id: 'includeItems',
title: 'Include Items',
type: 'dropdown',
options: [
{ label: 'Yes', id: 'true' },
{ label: 'No', id: 'false' },
],
value: () => 'true',
condition: { field: 'operation', value: 'read_list' },
mode: 'advanced',
},
{
id: 'nextPageUrl',
title: 'Next Page URL',
type: 'short-input',
placeholder: 'Paste the @odata.nextLink URL from a previous result',
condition: {
field: 'operation',
value: ['read_page', 'list_sites', 'read_list'],
},
mode: 'advanced',
},
// Upload / Download / Delete File / Get Drive Item operation fields
{
id: 'driveId',
title: 'Document Library ID',
type: 'short-input',
placeholder: 'Enter document library (drive) ID',
canonicalParamId: 'driveId',
condition: { field: 'operation', value: 'upload_file' },
condition: {
field: 'operation',
value: ['upload_file', 'download_file', 'delete_file', 'get_drive_item'],
},
required: { field: 'operation', value: ['download_file', 'delete_file', 'get_drive_item'] },
mode: 'advanced',
},
{
id: 'driveItemId',
title: 'File ID',
type: 'short-input',
placeholder: 'Enter the file (drive item) ID',
canonicalParamId: 'driveItemId',
condition: { field: 'operation', value: ['download_file', 'delete_file', 'get_drive_item'] },
required: { field: 'operation', value: ['download_file', 'delete_file', 'get_drive_item'] },
},
{
id: 'folderPath',
title: 'Folder Path',
@@ -352,8 +478,8 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
id: 'fileName',
title: 'File Name',
type: 'short-input',
placeholder: 'Optional: override uploaded file name',
condition: { field: 'operation', value: 'upload_file' },
placeholder: 'Optional: override uploaded/downloaded file name',
condition: { field: 'operation', value: ['upload_file', 'download_file'] },
mode: 'advanced',
required: false,
},
@@ -367,7 +493,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
condition: { field: 'operation', value: 'upload_file' },
mode: 'basic',
multiple: true,
required: false,
required: true,
},
// Variable reference (advanced mode)
{
@@ -378,19 +504,27 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
placeholder: 'Reference files from previous blocks',
condition: { field: 'operation', value: 'upload_file' },
mode: 'advanced',
required: false,
required: true,
},
],
tools: {
access: [
'sharepoint_create_page',
'sharepoint_read_page',
'sharepoint_update_page',
'sharepoint_publish_page',
'sharepoint_delete_page',
'sharepoint_list_sites',
'sharepoint_create_list',
'sharepoint_get_list',
'sharepoint_update_list',
'sharepoint_add_list_items',
'sharepoint_get_list_item',
'sharepoint_delete_list_item',
'sharepoint_upload_file',
'sharepoint_download_file',
'sharepoint_get_drive_item',
'sharepoint_delete_file',
],
config: {
tool: (params) => {
@@ -399,6 +533,12 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
return 'sharepoint_create_page'
case 'read_page':
return 'sharepoint_read_page'
case 'update_page':
return 'sharepoint_update_page'
case 'publish_page':
return 'sharepoint_publish_page'
case 'delete_page':
return 'sharepoint_delete_page'
case 'list_sites':
return 'sharepoint_list_sites'
case 'create_list':
@@ -409,8 +549,18 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
return 'sharepoint_update_list'
case 'add_list_items':
return 'sharepoint_add_list_items'
case 'get_list_item':
return 'sharepoint_get_list_item'
case 'delete_list_item':
return 'sharepoint_delete_list_item'
case 'upload_file':
return 'sharepoint_upload_file'
case 'download_file':
return 'sharepoint_download_file'
case 'get_drive_item':
return 'sharepoint_get_drive_item'
case 'delete_file':
return 'sharepoint_delete_file'
default:
throw new Error(`Invalid Sharepoint operation: ${params.operation}`)
}
@@ -428,6 +578,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
includeItems,
files, // canonical param from uploadFiles (basic) or files (advanced)
driveId, // canonical param from driveId
driveItemId, // canonical param from driveItemId
columnDefinitions,
listId,
...others
@@ -458,19 +609,16 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
}
if (others.operation === 'update_list' || others.operation === 'add_list_items') {
try {
logger.info('SharepointBlock list item param check', {
siteId: effectiveSiteId || undefined,
listId: listId,
listTitle: (others as any)?.listTitle,
itemId: sanitizedItemId,
hasItemFields: !!parsedItemFields && typeof parsedItemFields === 'object',
itemFieldKeys:
parsedItemFields && typeof parsedItemFields === 'object'
? Object.keys(parsedItemFields)
: [],
})
} catch {}
logger.info('SharepointBlock list item param check', {
siteId: effectiveSiteId || undefined,
listId: listId,
itemId: sanitizedItemId,
hasItemFields: !!parsedItemFields && typeof parsedItemFields === 'object',
itemFieldKeys:
parsedItemFields && typeof parsedItemFields === 'object'
? Object.keys(parsedItemFields)
: [],
})
}
// Handle file upload files parameter using canonical param
@@ -478,11 +626,10 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
const baseParams: Record<string, any> = {
oauthCredential,
siteId: effectiveSiteId || undefined,
pageSize: others.pageSize ? Number.parseInt(others.pageSize as string, 10) : undefined,
mimeType: mimeType,
...others,
...(listId ? { listId } : {}),
...(driveId ? { driveId } : {}),
...(driveItemId ? { driveItemId: String(driveItemId).trim() } : {}),
itemId: sanitizedItemId,
listItemFields: parsedItemFields,
includeColumns: coerceBoolean(includeColumns),
@@ -494,7 +641,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
baseParams.files = normalizedFiles
}
if (columnDefinitions) {
if (columnDefinitions && others.operation === 'create_list') {
baseParams.pageContent = columnDefinitions
}
@@ -511,14 +658,13 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
description: 'Column definitions for list creation (JSON array)',
},
pageTitle: { type: 'string', description: 'Page title' },
pageContent: { type: 'string', description: 'Page text content' },
pageId: { type: 'string', description: 'Page ID' },
siteId: { type: 'string', description: 'Site ID' },
pageSize: { type: 'number', description: 'Results per page' },
listDisplayName: { type: 'string', description: 'List display name' },
listDescription: { type: 'string', description: 'List description' },
listTemplate: { type: 'string', description: 'List template' },
listId: { type: 'string', description: 'List ID' },
listTitle: { type: 'string', description: 'List title' },
includeColumns: { type: 'boolean', description: 'Include columns in response' },
includeItems: { type: 'boolean', description: 'Include items in response' },
itemId: { type: 'string', description: 'List item ID (canonical param)' },
@@ -527,9 +673,16 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
type: 'string',
description: 'Document library (drive) ID',
},
driveItemId: { type: 'string', description: 'File (drive item) ID (canonical param)' },
folderPath: { type: 'string', description: 'Folder path for file upload' },
fileName: { type: 'string', description: 'File name override' },
files: { type: 'array', description: 'Files to upload' },
maxPages: { type: 'number', description: 'Maximum pages to return when reading all pages' },
groupId: { type: 'string', description: 'Microsoft 365 group ID for group-owned sites' },
nextPageUrl: {
type: 'string',
description: 'Full Microsoft Graph @odata.nextLink URL from a previous result',
},
},
outputs: {
sites: {
@@ -537,10 +690,40 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
description:
'An array of SharePoint site objects, each containing details such as id, name, and more.',
},
site: {
type: 'json',
description: 'Single SharePoint site object (id, name, displayName, webUrl)',
},
page: {
type: 'json',
description: 'SharePoint page object (id, name, title, webUrl, pageLayout)',
},
pages: {
type: 'json',
description: 'Array of SharePoint pages with content ([{page, content}])',
},
content: {
type: 'json',
description: 'Content of the SharePoint page (content, canvasLayout)',
},
totalPages: {
type: 'number',
description: 'Total number of pages found when listing all pages',
},
nextPageUrl: {
type: 'string',
description: 'Full Microsoft Graph @odata.nextLink URL for the next page of results',
},
published: { type: 'boolean', description: 'Whether the page was published' },
deleted: { type: 'boolean', description: 'Whether the item/page/file was deleted' },
list: {
type: 'json',
description: 'SharePoint list object (id, displayName, name, webUrl, etc.)',
},
lists: {
type: 'json',
description: 'Array of SharePoint list objects when no specific list is requested',
},
item: {
type: 'json',
description: 'SharePoint list item with fields',
@@ -549,6 +732,14 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
type: 'json',
description: 'Array of SharePoint list items with fields',
},
driveItem: {
type: 'json',
description: 'SharePoint drive item metadata (id, name, size, webUrl, file/folder facet)',
},
file: {
type: 'json',
description: 'Downloaded file stored in execution files',
},
uploadedFiles: {
type: 'json',
description: 'Array of uploaded file objects with id, name, webUrl, size',
@@ -557,6 +748,21 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
type: 'number',
description: 'Number of files uploaded',
},
skippedFiles: {
type: 'json',
description:
'Files skipped during upload for exceeding the size limit (name, size, limit, reason)',
},
skippedCount: {
type: 'number',
description: 'Number of files skipped during upload',
},
errors: {
type: 'json',
description: 'Per-file upload errors ([{name, error, status}])',
},
itemId: { type: 'string', description: 'ID of the deleted list item or file' },
pageId: { type: 'string', description: 'ID of the deleted or published page' },
success: {
type: 'boolean',
description: 'Success status',
@@ -571,21 +777,48 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
const SHAREPOINT_V2_TOOL_IDS = [
'sharepoint_create_page',
'sharepoint_read_page',
'sharepoint_update_page',
'sharepoint_publish_page',
'sharepoint_delete_page',
'sharepoint_list_sites',
'sharepoint_create_list',
'sharepoint_get_list',
'sharepoint_update_list',
'sharepoint_add_list_items',
'sharepoint_get_list_item',
'sharepoint_delete_list_item',
'sharepoint_upload_file',
'sharepoint_download_file',
'sharepoint_get_drive_item',
'sharepoint_delete_file',
] as const
const SHAREPOINT_V2_SITE_OPERATIONS = Array.from(SHAREPOINT_V2_TOOL_IDS)
const SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS = [
'sharepoint_download_file',
'sharepoint_delete_file',
'sharepoint_get_drive_item',
] as const
const SHAREPOINT_V2_SITE_OPERATIONS = SHAREPOINT_V2_TOOL_IDS.filter(
(id) => !(SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS as readonly string[]).includes(id)
)
const SHAREPOINT_V2_LIST_ITEM_OPERATIONS = [
'sharepoint_update_list',
'sharepoint_add_list_items',
] as const
const SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS = [
'sharepoint_get_list_item',
'sharepoint_delete_list_item',
] as const
const SHAREPOINT_V2_PAGE_MUTATION_OPERATIONS = [
'sharepoint_update_page',
'sharepoint_publish_page',
'sharepoint_delete_page',
] as const
export const SharepointV2Block: BlockConfig<SharepointResponse> = {
...SharepointBlock,
type: 'sharepoint_v2',
@@ -599,12 +832,20 @@ export const SharepointV2Block: BlockConfig<SharepointResponse> = {
options: [
{ label: 'Create Page', id: 'sharepoint_create_page' },
{ label: 'Read Page', id: 'sharepoint_read_page' },
{ label: 'Update Page', id: 'sharepoint_update_page' },
{ label: 'Publish Page', id: 'sharepoint_publish_page' },
{ label: 'Delete Page', id: 'sharepoint_delete_page' },
{ label: 'List Sites', id: 'sharepoint_list_sites' },
{ label: 'Create List', id: 'sharepoint_create_list' },
{ label: 'Read List', id: 'sharepoint_get_list' },
{ label: 'Update List Item', id: 'sharepoint_update_list' },
{ label: 'Add List Item', id: 'sharepoint_add_list_items' },
{ label: 'Get List Item', id: 'sharepoint_get_list_item' },
{ label: 'Delete List Item', id: 'sharepoint_delete_list_item' },
{ label: 'Upload File', id: 'sharepoint_upload_file' },
{ label: 'Download File', id: 'sharepoint_download_file' },
{ label: 'Get Drive Item', id: 'sharepoint_get_drive_item' },
{ label: 'Delete File', id: 'sharepoint_delete_file' },
],
value: () => 'sharepoint_create_page',
},
@@ -664,8 +905,12 @@ export const SharepointV2Block: BlockConfig<SharepointResponse> = {
id: 'pageId',
title: 'Page ID',
type: 'short-input',
placeholder: 'Page ID (alternative to page name)',
condition: { field: 'operation', value: 'sharepoint_read_page' },
placeholder: 'Page ID (alternative to page name for Read Page)',
condition: {
field: 'operation',
value: ['sharepoint_read_page', ...SHAREPOINT_V2_PAGE_MUTATION_OPERATIONS],
},
required: { field: 'operation', value: [...SHAREPOINT_V2_PAGE_MUTATION_OPERATIONS] },
mode: 'advanced',
},
{
@@ -673,7 +918,10 @@ export const SharepointV2Block: BlockConfig<SharepointResponse> = {
title: 'Page Title',
type: 'short-input',
placeholder: 'Optional title (defaults to page name)',
condition: { field: 'operation', value: 'sharepoint_create_page' },
condition: {
field: 'operation',
value: ['sharepoint_create_page', 'sharepoint_update_page'],
},
mode: 'advanced',
},
{
@@ -681,7 +929,10 @@ export const SharepointV2Block: BlockConfig<SharepointResponse> = {
title: 'Page Content',
type: 'long-input',
placeholder: 'Optional text content for the page',
condition: { field: 'operation', value: 'sharepoint_create_page' },
condition: {
field: 'operation',
value: ['sharepoint_create_page', 'sharepoint_update_page'],
},
mode: 'advanced',
},
{
@@ -712,9 +963,19 @@ export const SharepointV2Block: BlockConfig<SharepointResponse> = {
mode: 'basic',
condition: {
field: 'operation',
value: ['sharepoint_get_list', ...SHAREPOINT_V2_LIST_ITEM_OPERATIONS],
value: [
'sharepoint_get_list',
...SHAREPOINT_V2_LIST_ITEM_OPERATIONS,
...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS,
],
},
required: {
field: 'operation',
value: [
...SHAREPOINT_V2_LIST_ITEM_OPERATIONS,
...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS,
],
},
required: { field: 'operation', value: [...SHAREPOINT_V2_LIST_ITEM_OPERATIONS] },
},
{
id: 'manualListId',
@@ -725,9 +986,19 @@ export const SharepointV2Block: BlockConfig<SharepointResponse> = {
mode: 'advanced',
condition: {
field: 'operation',
value: ['sharepoint_get_list', ...SHAREPOINT_V2_LIST_ITEM_OPERATIONS],
value: [
'sharepoint_get_list',
...SHAREPOINT_V2_LIST_ITEM_OPERATIONS,
...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS,
],
},
required: {
field: 'operation',
value: [
...SHAREPOINT_V2_LIST_ITEM_OPERATIONS,
...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS,
],
},
required: { field: 'operation', value: [...SHAREPOINT_V2_LIST_ITEM_OPERATIONS] },
},
{
id: 'includeColumns',
@@ -821,8 +1092,14 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
title: 'Item ID',
type: 'short-input',
placeholder: 'Enter item ID',
condition: { field: 'operation', value: 'sharepoint_update_list' },
required: { field: 'operation', value: 'sharepoint_update_list' },
condition: {
field: 'operation',
value: ['sharepoint_update_list', ...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS],
},
required: {
field: 'operation',
value: ['sharepoint_update_list', ...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS],
},
},
{
id: 'listItemFields',
@@ -848,9 +1125,21 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
title: 'Document Library ID',
type: 'short-input',
placeholder: 'Enter document library (drive) ID',
condition: { field: 'operation', value: 'sharepoint_upload_file' },
condition: {
field: 'operation',
value: [...SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS, 'sharepoint_upload_file'],
},
required: { field: 'operation', value: [...SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS] },
mode: 'advanced',
},
{
id: 'driveItemId',
title: 'File ID',
type: 'short-input',
placeholder: 'Enter the file (drive item) ID',
condition: { field: 'operation', value: [...SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS] },
required: { field: 'operation', value: [...SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS] },
},
{
id: 'folderPath',
title: 'Folder Path',
@@ -864,8 +1153,11 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
id: 'fileName',
title: 'File Name',
type: 'short-input',
placeholder: 'Optional: override uploaded file name',
condition: { field: 'operation', value: 'sharepoint_upload_file' },
placeholder: 'Optional: override uploaded/downloaded file name',
condition: {
field: 'operation',
value: ['sharepoint_upload_file', 'sharepoint_download_file'],
},
mode: 'advanced',
required: false,
},
@@ -919,6 +1211,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
files,
maxPages,
driveId,
driveItemId,
...rest
} = params
@@ -950,13 +1243,14 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
listId: cleanString(listId),
itemId: cleanString(itemId),
driveId: cleanString(driveId),
driveItemId: cleanString(driveItemId),
includeColumns: coerceBoolean(includeColumns),
includeItems: coerceBoolean(includeItems),
listItemFields: parseJsonObject(listItemFields),
maxPages: maxPages ? Number.parseInt(String(maxPages), 10) : undefined,
}
if (columnDefinitions) {
if (columnDefinitions && rest.operation === 'sharepoint_create_list') {
result.pageContent = columnDefinitions
}
if (normalizedFiles) {
@@ -991,6 +1285,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
itemId: { type: 'string', description: 'List item ID' },
listItemFields: { type: 'json', description: 'List item fields' },
driveId: { type: 'string', description: 'Document library (drive) ID' },
driveItemId: { type: 'string', description: 'File (drive item) ID' },
folderPath: { type: 'string', description: 'Folder path for file upload' },
fileName: { type: 'string', description: 'File name override' },
files: { type: 'json', description: 'Files to upload' },
@@ -1017,6 +1312,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
description: 'SharePoint page content (content, canvasLayout)',
},
totalPages: { type: 'number', description: 'Number of pages returned' },
published: { type: 'boolean', description: 'Whether the page was published' },
list: {
type: 'json',
description: 'SharePoint list object (id, displayName, name, webUrl, columns, items)',
@@ -1027,6 +1323,15 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
},
item: { type: 'json', description: 'SharePoint list item with fields' },
items: { type: 'json', description: 'Array of SharePoint list items with fields' },
deleted: { type: 'boolean', description: 'Whether the item/page/file was deleted' },
driveItem: {
type: 'json',
description: 'SharePoint drive item metadata (id, name, size, webUrl, file/folder facet)',
},
file: {
type: 'json',
description: 'Downloaded file stored in execution files',
},
uploadedFiles: {
type: 'json',
description: 'Array of uploaded file objects with id, name, webUrl, size',
@@ -1041,6 +1346,8 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
type: 'json',
description: 'Array of per-file upload errors (name, error, status)',
},
itemId: { type: 'string', description: 'ID of the deleted list item or file' },
pageId: { type: 'string', description: 'ID of the deleted or published page' },
nextPageUrl: {
type: 'string',
description: 'Microsoft Graph @odata.nextLink URL for the next page of results',
@@ -86,6 +86,13 @@ export const sharepointUploadBodySchema = z.object({
files: RawFileInputArraySchema.optional().nullable(),
})
export const sharepointDownloadFileBodySchema = z.object({
accessToken: accessTokenSchema,
driveId: z.string().min(1, 'Drive ID is required'),
itemId: z.string().min(1, 'Item ID is required'),
fileName: z.string().optional().nullable(),
})
export const dataverseUploadFileBodySchema = z.object({
accessToken: accessTokenSchema,
environmentUrl: z.string().min(1, 'Environment URL is required'),
@@ -190,6 +197,13 @@ export const sharepointUploadContract = defineRouteContract({
response: { mode: 'json', schema: toolJsonResponseSchema },
})
export const sharepointDownloadFileContract = defineRouteContract({
method: 'POST',
path: '/api/tools/sharepoint/download-file',
body: sharepointDownloadFileBodySchema,
response: { mode: 'json', schema: toolJsonResponseSchema },
})
export const dataverseUploadFileContract = defineRouteContract({
method: 'POST',
path: '/api/tools/microsoft-dataverse/upload-file',
@@ -210,4 +224,5 @@ export type TeamsDeleteChatMessageBody = ContractBody<typeof teamsDeleteChatMess
export type OneDriveUploadBody = ContractBody<typeof onedriveUploadContract>
export type OneDriveDownloadBody = ContractBody<typeof onedriveDownloadContract>
export type SharepointUploadBody = ContractBody<typeof sharepointUploadContract>
export type SharepointDownloadFileBody = ContractBody<typeof sharepointDownloadFileContract>
export type DataverseUploadFileBody = z.output<typeof dataverseUploadFileBodySchema>
@@ -4,6 +4,7 @@ import https from 'https'
import type { LookupFunction } from 'net'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { omit } from '@sim/utils/object'
import * as ipaddr from 'ipaddr.js'
import { Agent, type RequestInit as UndiciRequestInit, fetch as undiciFetch } from 'undici'
import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags'
@@ -333,6 +334,8 @@ export interface SecureFetchOptions {
maxRedirects?: number
maxResponseBytes?: number
signal?: AbortSignal
/** Drop the Authorization header when following a redirect, so it is not sent to the redirect target's origin. */
stripAuthOnRedirect?: boolean
}
export class SecureFetchHeaders {
@@ -500,10 +503,16 @@ export async function secureFetchWithPinnedIP(
settledReject(new Error(`Redirect blocked: ${validation.error}`))
return
}
const redirectOptions = options.stripAuthOnRedirect
? {
...options,
headers: omit(options.headers ?? {}, ['Authorization', 'authorization']),
}
: options
return secureFetchWithPinnedIP(
redirectUrl,
validation.resolvedIP!,
options,
redirectOptions,
redirectCount + 1
)
})
+16
View File
@@ -3175,10 +3175,18 @@ import {
sharepointAddListItemTool,
sharepointCreateListTool,
sharepointCreatePageTool,
sharepointDeleteFileTool,
sharepointDeleteListItemTool,
sharepointDeletePageTool,
sharepointDownloadFileTool,
sharepointGetDriveItemTool,
sharepointGetListItemTool,
sharepointGetListTool,
sharepointListSitesTool,
sharepointPublishPageTool,
sharepointReadPageTool,
sharepointUpdateListItemTool,
sharepointUpdatePageTool,
sharepointUploadFileTool,
} from '@/tools/sharepoint'
import {
@@ -7679,10 +7687,18 @@ export const tools: Record<string, ToolConfig> = {
sharepoint_add_list_items: sharepointAddListItemTool,
sharepoint_create_list: sharepointCreateListTool,
sharepoint_create_page: sharepointCreatePageTool,
sharepoint_delete_file: sharepointDeleteFileTool,
sharepoint_delete_list_item: sharepointDeleteListItemTool,
sharepoint_delete_page: sharepointDeletePageTool,
sharepoint_download_file: sharepointDownloadFileTool,
sharepoint_get_drive_item: sharepointGetDriveItemTool,
sharepoint_get_list: sharepointGetListTool,
sharepoint_get_list_item: sharepointGetListItemTool,
sharepoint_list_sites: sharepointListSitesTool,
sharepoint_publish_page: sharepointPublishPageTool,
sharepoint_read_page: sharepointReadPageTool,
sharepoint_update_list: sharepointUpdateListItemTool,
sharepoint_update_page: sharepointUpdatePageTool,
sharepoint_upload_file: sharepointUploadFileTool,
stripe_create_payment_intent: stripeCreatePaymentIntentTool,
stripe_retrieve_payment_intent: stripeRetrievePaymentIntentTool,
+36 -76
View File
@@ -1,9 +1,28 @@
import { createLogger } from '@sim/logger'
import type { SharepointAddListItemResponse, SharepointToolParams } from '@/tools/sharepoint/types'
import { optionalTrim } from '@/tools/sharepoint/utils'
import { optionalTrim, sanitizeListItemFields } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SharePointAddListItem')
function resolveSanitizedFields(
listItemFields: SharepointToolParams['listItemFields']
): Record<string, unknown> {
if (!listItemFields || Object.keys(listItemFields).length === 0) {
throw new Error('listItemFields must not be empty')
}
const providedFields =
typeof listItemFields === 'object' &&
listItemFields !== null &&
'fields' in (listItemFields as Record<string, unknown>) &&
Object.keys(listItemFields as Record<string, unknown>).length === 1
? ((listItemFields as { fields: Record<string, unknown> }).fields as Record<string, unknown>)
: (listItemFields as Record<string, unknown>)
if (!providedFields || Object.keys(providedFields).length === 0) {
throw new Error('No fields provided to create the SharePoint list item')
}
return sanitizeListItemFields(providedFields, { action: 'create' })
}
export const addListItemTool: ToolConfig<SharepointToolParams, SharepointAddListItemResponse> = {
id: 'sharepoint_add_list_items',
@@ -58,7 +77,7 @@ export const addListItemTool: ToolConfig<SharepointToolParams, SharepointAddList
throw new Error('listId must be provided')
}
const listSegment = encodeURIComponent(listId)
return `https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${listSegment}/items`
return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists/${listSegment}/items`
},
method: 'POST',
headers: (params) => ({
@@ -66,87 +85,28 @@ export const addListItemTool: ToolConfig<SharepointToolParams, SharepointAddList
'Content-Type': 'application/json',
Accept: 'application/json',
}),
body: (params) => {
if (!params.listItemFields || Object.keys(params.listItemFields).length === 0) {
throw new Error('listItemFields must not be empty')
}
const providedFields =
typeof params.listItemFields === 'object' &&
params.listItemFields !== null &&
'fields' in (params.listItemFields as Record<string, unknown>) &&
Object.keys(params.listItemFields as Record<string, unknown>).length === 1
? ((params.listItemFields as { fields: Record<string, unknown> }).fields as Record<
string,
unknown
>)
: (params.listItemFields as Record<string, unknown>)
if (!providedFields || Object.keys(providedFields).length === 0) {
throw new Error('No fields provided to create the SharePoint list item')
}
const readOnlyFields = new Set<string>([
'Id',
'id',
'UniqueId',
'GUID',
'ContentTypeId',
'Created',
'Modified',
'Author',
'Editor',
'CreatedBy',
'ModifiedBy',
'AuthorId',
'EditorId',
'_UIVersionString',
'Attachments',
'FileRef',
'FileDirRef',
'FileLeafRef',
])
const entries = Object.entries(providedFields)
const creatableEntries = entries.filter(([key]) => !readOnlyFields.has(key))
if (creatableEntries.length !== entries.length) {
const removed = entries.filter(([key]) => readOnlyFields.has(key)).map(([key]) => key)
logger.warn('Removed read-only SharePoint fields from create', {
removed,
})
}
if (creatableEntries.length === 0) {
const requestedKeys = Object.keys(providedFields)
throw new Error(
`All provided fields are read-only and cannot be set: ${requestedKeys.join(', ')}`
)
}
const sanitizedFields = Object.fromEntries(creatableEntries)
logger.info('Creating SharePoint list item', {
listId: params.listId,
fieldsKeys: Object.keys(sanitizedFields),
})
return {
fields: sanitizedFields,
}
},
body: (params) => ({
fields: resolveSanitizedFields(params.listItemFields),
}),
},
transformResponse: async (response: Response, params) => {
let data: any
let data: Record<string, unknown> | undefined
try {
data = await response.json()
} catch {
data = undefined
}
const itemId: string | undefined = data?.id
const fields: Record<string, unknown> | undefined = data?.fields || params?.listItemFields
const itemId = data?.id as string | undefined
let fields = data?.fields as Record<string, unknown> | undefined
if (!fields && params) {
try {
fields = resolveSanitizedFields(params.listItemFields)
} catch {
// Item was already created successfully; a malformed fallback input must not fail the response.
}
}
return {
success: true,
+2 -2
View File
@@ -70,8 +70,8 @@ export const createListTool: ToolConfig<SharepointToolParams, SharepointCreateLi
request: {
url: (params) => {
const siteId = optionalTrim(params.siteSelector) || optionalTrim(params.siteId) || 'root'
return `https://graph.microsoft.com/v1.0/sites/${siteId}/lists`
const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists`
},
method: 'POST',
headers: (params) => ({
+4 -4
View File
@@ -4,7 +4,7 @@ import type {
SharepointPage,
SharepointToolParams,
} from '@/tools/sharepoint/types'
import { optionalTrim } from '@/tools/sharepoint/utils'
import { escapeHtml, optionalTrim } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SharePointCreatePage')
@@ -61,8 +61,8 @@ export const createPageTool: ToolConfig<SharepointToolParams, SharepointCreatePa
request: {
url: (params) => {
const siteId = optionalTrim(params.siteSelector) || optionalTrim(params.siteId) || 'root'
return `https://graph.microsoft.com/v1.0/sites/${siteId}/pages`
const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages`
},
method: 'POST',
headers: (params) => ({
@@ -103,7 +103,7 @@ export const createPageTool: ToolConfig<SharepointToolParams, SharepointCreatePa
webparts: [
{
id: '6f9230af-2a98-4952-b205-9ede4f9ef548',
innerHtml: `<p>${pageContent.replace(/"/g, '&quot;').replace(/'/g, '&#39;')}</p>`,
innerHtml: `<p>${escapeHtml(pageContent)}</p>`,
},
],
},
+66
View File
@@ -0,0 +1,66 @@
import type { SharepointDeleteFileResponse, SharepointToolParams } from '@/tools/sharepoint/types'
import { optionalTrim } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
export const deleteFileTool: ToolConfig<SharepointToolParams, SharepointDeleteFileResponse> = {
id: 'sharepoint_delete_file',
name: 'Delete SharePoint File',
description: 'Delete a file (or folder) from a SharePoint document library',
version: '1.0.0',
oauth: {
required: true,
provider: 'sharepoint',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the SharePoint API',
},
driveId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the document library (drive). Example: b!abc123def456',
},
driveItemId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the file (drive item) to delete',
},
},
request: {
url: (params) => {
const driveId = optionalTrim(params.driveId)
const driveItemId = optionalTrim(params.driveItemId)
if (!driveId) throw new Error('driveId must be provided')
if (!driveItemId) throw new Error('driveItemId must be provided')
return `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(driveItemId)}`
},
method: 'DELETE',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
Accept: 'application/json',
}),
},
transformResponse: async (_response: Response, params) => {
return {
success: true,
output: {
deleted: true,
itemId: params?.driveItemId ?? '',
},
}
},
outputs: {
deleted: { type: 'boolean', description: 'Whether the file was deleted' },
itemId: { type: 'string', description: 'The ID of the deleted file' },
},
}
@@ -0,0 +1,88 @@
import type {
SharepointDeleteListItemResponse,
SharepointToolParams,
} from '@/tools/sharepoint/types'
import { optionalTrim } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
export const deleteListItemTool: ToolConfig<
SharepointToolParams,
SharepointDeleteListItemResponse
> = {
id: 'sharepoint_delete_list_item',
name: 'Delete SharePoint List Item',
description: 'Delete an item from a SharePoint list',
version: '1.0.0',
oauth: {
required: true,
provider: 'sharepoint',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the SharePoint API',
},
siteSelector: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Select the SharePoint site',
},
siteId: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'The ID of the SharePoint site (internal use)',
},
listId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012',
},
itemId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the list item to delete. Example: 1, 42, or 123',
},
},
request: {
url: (params) => {
const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
const listId = optionalTrim(params.listId)
const itemId = optionalTrim(params.itemId)
if (!listId) throw new Error('listId must be provided')
if (!itemId) throw new Error('itemId must be provided')
const listSegment = encodeURIComponent(listId)
const itemSegment = encodeURIComponent(itemId)
return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists/${listSegment}/items/${itemSegment}`
},
method: 'DELETE',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
Accept: 'application/json',
}),
},
transformResponse: async (_response: Response, params) => {
return {
success: true,
output: {
deleted: true,
itemId: params?.itemId ?? '',
},
}
},
outputs: {
deleted: { type: 'boolean', description: 'Whether the list item was deleted' },
itemId: { type: 'string', description: 'The ID of the deleted list item' },
},
}
+72
View File
@@ -0,0 +1,72 @@
import type { SharepointDeletePageResponse, SharepointToolParams } from '@/tools/sharepoint/types'
import { optionalTrim } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
export const deletePageTool: ToolConfig<SharepointToolParams, SharepointDeletePageResponse> = {
id: 'sharepoint_delete_page',
name: 'Delete SharePoint Page',
description: 'Delete a page from a SharePoint site',
version: '1.0.0',
oauth: {
required: true,
provider: 'sharepoint',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the SharePoint API',
},
siteSelector: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Select the SharePoint site',
},
siteId: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'The ID of the SharePoint site (internal use)',
},
pageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'The ID of the page to delete. Example: a GUID like 12345678-1234-1234-1234-123456789012',
},
},
request: {
url: (params) => {
const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
const pageId = optionalTrim(params.pageId)
if (!pageId) throw new Error('pageId must be provided')
return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageId)}`
},
method: 'DELETE',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
Accept: 'application/json',
}),
},
transformResponse: async (_response: Response, params) => {
return {
success: true,
output: {
deleted: true,
pageId: params?.pageId ?? '',
},
}
},
outputs: {
deleted: { type: 'boolean', description: 'Whether the page was deleted' },
pageId: { type: 'string', description: 'The ID of the deleted page' },
},
}
@@ -0,0 +1,59 @@
import type { SharepointDownloadFileResponse, SharepointToolParams } from '@/tools/sharepoint/types'
import type { ToolConfig } from '@/tools/types'
export const downloadFileTool: ToolConfig<SharepointToolParams, SharepointDownloadFileResponse> = {
id: 'sharepoint_download_file',
name: 'Download File from SharePoint',
description: 'Download a file from a SharePoint document library',
version: '1.0.0',
oauth: {
required: true,
provider: 'sharepoint',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the SharePoint API',
},
driveId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the document library (drive). Example: b!abc123def456',
},
driveItemId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the file (drive item) to download',
},
fileName: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optional filename override (e.g., "report.pdf", "data.xlsx")',
},
},
request: {
url: '/api/tools/sharepoint/download-file',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
accessToken: params.accessToken,
driveId: params.driveId,
itemId: params.driveItemId,
fileName: params.fileName,
}),
},
outputs: {
file: { type: 'file', description: 'Downloaded file stored in execution files' },
},
}
+106
View File
@@ -0,0 +1,106 @@
import type {
SharepointDriveItem,
SharepointGetDriveItemResponse,
SharepointToolParams,
} from '@/tools/sharepoint/types'
import { optionalTrim } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
export const getDriveItemTool: ToolConfig<SharepointToolParams, SharepointGetDriveItemResponse> = {
id: 'sharepoint_get_drive_item',
name: 'Get SharePoint Drive Item',
description: 'Get metadata for a file or folder in a SharePoint document library',
version: '1.0.0',
oauth: {
required: true,
provider: 'sharepoint',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the SharePoint API',
},
driveId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the document library (drive). Example: b!abc123def456',
},
driveItemId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the file or folder (drive item) to retrieve',
},
},
request: {
url: (params) => {
const driveId = optionalTrim(params.driveId)
const driveItemId = optionalTrim(params.driveItemId)
if (!driveId) throw new Error('driveId must be provided')
if (!driveItemId) throw new Error('driveItemId must be provided')
return `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(driveItemId)}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
Accept: 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data: Record<string, unknown> = await response.json()
const driveItem: SharepointDriveItem = {
id: data.id as string,
name: data.name as string,
webUrl: data.webUrl as string | undefined,
size: data.size as number | undefined,
createdDateTime: data.createdDateTime as string | undefined,
lastModifiedDateTime: data.lastModifiedDateTime as string | undefined,
file: (data.file as SharepointDriveItem['file']) ?? null,
folder: (data.folder as SharepointDriveItem['folder']) ?? null,
parentReference: (data.parentReference as SharepointDriveItem['parentReference']) ?? null,
}
return {
success: true,
output: { driveItem },
}
},
outputs: {
driveItem: {
type: 'object',
description: 'Metadata for the SharePoint file or folder',
properties: {
id: { type: 'string', description: 'The unique ID of the drive item' },
name: { type: 'string', description: 'The name of the file or folder' },
webUrl: { type: 'string', description: 'The URL to access the item' },
size: { type: 'number', description: 'The size of the item in bytes', optional: true },
createdDateTime: { type: 'string', description: 'When the item was created' },
lastModifiedDateTime: { type: 'string', description: 'When the item was last modified' },
file: {
type: 'object',
description: 'Present if the item is a file (contains mimeType)',
optional: true,
},
folder: {
type: 'object',
description: 'Present if the item is a folder (contains childCount)',
optional: true,
},
parentReference: {
type: 'object',
description: 'Reference to the parent folder/drive',
optional: true,
},
},
},
},
}
+47 -41
View File
@@ -73,10 +73,11 @@ export const getListTool: ToolConfig<SharepointToolParams, SharepointGetListResp
}
const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
const encodedSiteId = encodeURIComponent(siteId)
const listId = optionalTrim(params.listId)
if (!listId) {
const baseUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/lists`
const baseUrl = `https://graph.microsoft.com/v1.0/sites/${encodedSiteId}/lists`
const url = new URL(baseUrl)
const finalUrl = url.toString()
logger.info('SharePoint List All Lists URL', {
@@ -91,7 +92,7 @@ export const getListTool: ToolConfig<SharepointToolParams, SharepointGetListResp
if (wantsItems && !params.includeColumns) {
const itemsUrl = new URL(
`https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${listSegment}/items`
`https://graph.microsoft.com/v1.0/sites/${encodedSiteId}/lists/${listSegment}/items`
)
itemsUrl.searchParams.set('$expand', 'fields')
const finalItemsUrl = itemsUrl.toString()
@@ -103,11 +104,11 @@ export const getListTool: ToolConfig<SharepointToolParams, SharepointGetListResp
return finalItemsUrl
}
const baseUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${listSegment}`
const baseUrl = `https://graph.microsoft.com/v1.0/sites/${encodedSiteId}/lists/${listSegment}`
const url = new URL(baseUrl)
const expandParts: string[] = []
if (params.includeColumns) expandParts.push('columns')
if (wantsItems) expandParts.push('items($expand=fields)')
if (wantsItems) expandParts.push('items(expand=fields)')
if (expandParts.length > 0) url.searchParams.append('$expand', expandParts.join(','))
const finalUrl = url.toString()
@@ -128,21 +129,23 @@ export const getListTool: ToolConfig<SharepointToolParams, SharepointGetListResp
},
transformResponse: async (response: Response, params) => {
const data = await response.json()
const data: Record<string, unknown> = await response.json()
const value = data.value
// If the response is a collection of items (from the items endpoint)
if (
Array.isArray((data as any).value) &&
(data as any).value.length > 0 &&
(data as any).value[0] &&
'fields' in (data as any).value[0]
Array.isArray(value) &&
value.length > 0 &&
value[0] &&
typeof value[0] === 'object' &&
'fields' in value[0]
) {
const items = (data as any).value.map((i: any) => ({
id: i.id,
const items = value.map((i: Record<string, unknown>) => ({
id: i.id as string,
fields: i.fields as Record<string, unknown>,
}))
const nextPageUrl = getGraphNextPageUrl(data as Record<string, unknown>)
const nextPageUrl = getGraphNextPageUrl(data)
return {
success: true,
@@ -154,18 +157,18 @@ export const getListTool: ToolConfig<SharepointToolParams, SharepointGetListResp
}
}
if (Array.isArray((data as any).value)) {
const lists: SharepointList[] = (data as any).value.map((l: any) => ({
id: l.id,
displayName: l.displayName ?? l.name,
name: l.name,
webUrl: l.webUrl,
createdDateTime: l.createdDateTime,
lastModifiedDateTime: l.lastModifiedDateTime,
list: l.list,
if (Array.isArray(value)) {
const lists: SharepointList[] = value.map((l: Record<string, unknown>) => ({
id: l.id as string,
displayName: (l.displayName ?? l.name) as string | undefined,
name: l.name as string | undefined,
webUrl: l.webUrl as string | undefined,
createdDateTime: l.createdDateTime as string | undefined,
lastModifiedDateTime: l.lastModifiedDateTime as string | undefined,
list: l.list as SharepointList['list'],
}))
const nextPageUrl = getGraphNextPageUrl(data as Record<string, unknown>)
const nextPageUrl = getGraphNextPageUrl(data)
return {
success: true,
@@ -174,29 +177,32 @@ export const getListTool: ToolConfig<SharepointToolParams, SharepointGetListResp
}
const list: SharepointList = {
id: data.id,
displayName: data.displayName ?? data.name,
name: data.name,
webUrl: data.webUrl,
createdDateTime: data.createdDateTime,
lastModifiedDateTime: data.lastModifiedDateTime,
list: data.list,
id: data.id as string,
displayName: (data.displayName ?? data.name) as string | undefined,
name: data.name as string | undefined,
webUrl: data.webUrl as string | undefined,
createdDateTime: data.createdDateTime as string | undefined,
lastModifiedDateTime: data.lastModifiedDateTime as string | undefined,
list: data.list as SharepointList['list'],
columns: Array.isArray(data.columns)
? data.columns.map((c: any) => ({
id: c.id,
name: c.name,
displayName: c.displayName,
description: c.description,
indexed: c.indexed,
enforcedUniqueValues: c.enforcedUniqueValues,
hidden: c.hidden,
readOnly: c.readOnly,
required: c.required,
columnGroup: c.columnGroup,
? data.columns.map((c: Record<string, unknown>) => ({
id: c.id as string | undefined,
name: c.name as string | undefined,
displayName: c.displayName as string | undefined,
description: c.description as string | undefined,
indexed: c.indexed as boolean | undefined,
enforcedUniqueValues: c.enforcedUniqueValues as boolean | undefined,
hidden: c.hidden as boolean | undefined,
readOnly: c.readOnly as boolean | undefined,
required: c.required as boolean | undefined,
columnGroup: c.columnGroup as string | undefined,
}))
: undefined,
items: Array.isArray(data.items)
? data.items.map((i: any) => ({ id: i.id, fields: i.fields as Record<string, unknown> }))
? data.items.map((i: Record<string, unknown>) => ({
id: i.id as string,
fields: i.fields as Record<string, unknown>,
}))
: undefined,
}
@@ -0,0 +1,96 @@
import type { SharepointGetListItemResponse, SharepointToolParams } from '@/tools/sharepoint/types'
import { optionalTrim } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
export const getListItemTool: ToolConfig<SharepointToolParams, SharepointGetListItemResponse> = {
id: 'sharepoint_get_list_item',
name: 'Get SharePoint List Item',
description: 'Get a single item (with field values) from a SharePoint list',
version: '1.0.0',
oauth: {
required: true,
provider: 'sharepoint',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the SharePoint API',
},
siteSelector: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Select the SharePoint site',
},
siteId: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'The ID of the SharePoint site (internal use)',
},
listId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012',
},
itemId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the list item to retrieve. Example: 1, 42, or 123',
},
},
request: {
url: (params) => {
const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
const listId = optionalTrim(params.listId)
const itemId = optionalTrim(params.itemId)
if (!listId) throw new Error('listId must be provided')
if (!itemId) throw new Error('itemId must be provided')
const listSegment = encodeURIComponent(listId)
const itemSegment = encodeURIComponent(itemId)
const url = new URL(
`https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists/${listSegment}/items/${itemSegment}`
)
url.searchParams.set('$expand', 'fields')
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
Accept: 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data: Record<string, unknown> = await response.json()
return {
success: true,
output: {
item: {
id: data.id as string,
fields: data.fields as Record<string, unknown> | undefined,
},
},
}
},
outputs: {
item: {
type: 'object',
description: 'SharePoint list item with field values',
properties: {
id: { type: 'string', description: 'Item ID' },
fields: { type: 'object', description: 'Field values for the item' },
},
},
},
}
+17 -1
View File
@@ -1,19 +1,35 @@
import { addListItemTool } from '@/tools/sharepoint/add_list_items'
import { createListTool } from '@/tools/sharepoint/create_list'
import { createPageTool } from '@/tools/sharepoint/create_page'
import { deleteFileTool } from '@/tools/sharepoint/delete_file'
import { deleteListItemTool } from '@/tools/sharepoint/delete_list_item'
import { deletePageTool } from '@/tools/sharepoint/delete_page'
import { downloadFileTool } from '@/tools/sharepoint/download_file'
import { getDriveItemTool } from '@/tools/sharepoint/get_drive_item'
import { getListTool } from '@/tools/sharepoint/get_list'
import { getListItemTool } from '@/tools/sharepoint/get_list_item'
import { listSitesTool } from '@/tools/sharepoint/list_sites'
import { publishPageTool } from '@/tools/sharepoint/publish_page'
import { readPageTool } from '@/tools/sharepoint/read_page'
import { updateListItemTool } from '@/tools/sharepoint/update_list'
import { updatePageTool } from '@/tools/sharepoint/update_page'
import { uploadFileTool } from '@/tools/sharepoint/upload_file'
export const sharepointAddListItemTool = addListItemTool
export const sharepointCreatePageTool = createPageTool
export const sharepointCreateListTool = createListTool
export const sharepointDeleteFileTool = deleteFileTool
export const sharepointDeleteListItemTool = deleteListItemTool
export const sharepointDeletePageTool = deletePageTool
export const sharepointDownloadFileTool = downloadFileTool
export const sharepointGetDriveItemTool = getDriveItemTool
export const sharepointGetListTool = getListTool
export const sharepointGetListItemTool = getListItemTool
export const sharepointListSitesTool = listSitesTool
export const sharepointPublishPageTool = publishPageTool
export const sharepointReadPageTool = readPageTool
export const sharepointUpdateListItemTool = updateListItemTool
export const sharepointAddListItemTool = addListItemTool
export const sharepointUpdatePageTool = updatePageTool
export const sharepointUploadFileTool = uploadFileTool
export * from '@/tools/sharepoint/types'
+5 -5
View File
@@ -62,9 +62,9 @@ export const listSitesTool: ToolConfig<SharepointToolParams, SharepointReadSiteR
const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector)
if (groupId) {
baseUrl = `https://graph.microsoft.com/v1.0/groups/${groupId}/sites/root`
baseUrl = `https://graph.microsoft.com/v1.0/groups/${encodeURIComponent(groupId)}/sites/root`
} else if (siteId) {
baseUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}`
baseUrl = `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}`
} else {
baseUrl = 'https://graph.microsoft.com/v1.0/sites?search=*'
}
@@ -140,9 +140,9 @@ export const listSitesTool: ToolConfig<SharepointToolParams, SharepointReadSiteR
isPersonalSite: { type: 'boolean', description: 'Whether this is a personal site' },
root: {
type: 'object',
properties: {
serverRelativeUrl: { type: 'string', description: 'Server relative URL' },
},
description:
'Present (as an empty object) only when this site is the root of its site collection',
optional: true,
},
siteCollection: {
type: 'object',
+72
View File
@@ -0,0 +1,72 @@
import type { SharepointPublishPageResponse, SharepointToolParams } from '@/tools/sharepoint/types'
import { optionalTrim } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
export const publishPageTool: ToolConfig<SharepointToolParams, SharepointPublishPageResponse> = {
id: 'sharepoint_publish_page',
name: 'Publish SharePoint Page',
description: 'Publish the latest version of a SharePoint page, making it available to all users',
version: '1.0.0',
oauth: {
required: true,
provider: 'sharepoint',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the SharePoint API',
},
siteSelector: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Select the SharePoint site',
},
siteId: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'The ID of the SharePoint site (internal use)',
},
pageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'The ID of the page to publish. Example: a GUID like 12345678-1234-1234-1234-123456789012',
},
},
request: {
url: (params) => {
const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
const pageId = optionalTrim(params.pageId)
if (!pageId) throw new Error('pageId must be provided')
return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageId)}/microsoft.graph.sitePage/publish`
},
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
Accept: 'application/json',
}),
},
transformResponse: async (_response: Response, params) => {
return {
success: true,
output: {
published: true,
pageId: params?.pageId ?? '',
},
}
},
outputs: {
published: { type: 'boolean', description: 'Whether the page was published' },
pageId: { type: 'string', description: 'The ID of the published page' },
},
}
+11 -9
View File
@@ -85,12 +85,13 @@ export const readPageTool: ToolConfig<SharepointToolParams, SharepointReadPageRe
const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
const pageId = optionalTrim(params.pageId)
const encodedSiteId = encodeURIComponent(siteId)
let baseUrl: string
if (pageId) {
baseUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/pages/${pageId}/microsoft.graph.sitePage`
baseUrl = `https://graph.microsoft.com/v1.0/sites/${encodedSiteId}/pages/${encodeURIComponent(pageId)}/microsoft.graph.sitePage`
} else {
baseUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/pages/microsoft.graph.sitePage`
baseUrl = `https://graph.microsoft.com/v1.0/sites/${encodedSiteId}/pages/microsoft.graph.sitePage`
}
const url = new URL(baseUrl)
@@ -156,7 +157,7 @@ export const readPageTool: ToolConfig<SharepointToolParams, SharepointReadPageRe
const pageData = data
const contentData = {
content: extractTextFromCanvasLayout(data.canvasLayout),
canvasLayout: data.canvasLayout as any,
canvasLayout: data.canvasLayout ?? null,
}
return {
@@ -180,7 +181,7 @@ export const readPageTool: ToolConfig<SharepointToolParams, SharepointReadPageRe
if (!data.value || data.value.length === 0) {
logger.info('No pages found', {
searchName: params?.pageName,
siteId: params?.siteId || params?.siteSelector || 'root',
siteId: optionalTrim(params?.siteId) || optionalTrim(params?.siteSelector) || 'root',
totalResults: data.value?.length || 0,
})
const message = params?.pageName
@@ -199,14 +200,14 @@ export const readPageTool: ToolConfig<SharepointToolParams, SharepointReadPageRe
logger.info('Found pages', {
searchName: params?.pageName,
foundPages: data.value.map((p: any) => ({ id: p.id, name: p.name, title: p.title })),
foundPages: data.value.map((p) => ({ id: p.id, name: p.name, title: p.title })),
totalCount: data.value.length,
})
if (params?.pageName) {
const pageData = data.value[0]
const siteId = params?.siteId || params?.siteSelector || 'root'
const contentUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/pages/${pageData.id}/microsoft.graph.sitePage?$expand=canvasLayout`
const siteId = optionalTrim(params?.siteId) || optionalTrim(params?.siteSelector) || 'root'
const contentUrl = `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageData.id)}/microsoft.graph.sitePage?$expand=canvasLayout`
logger.info('Making API call to get page content for searched page', {
pageId: pageData.id,
@@ -253,7 +254,7 @@ export const readPageTool: ToolConfig<SharepointToolParams, SharepointReadPageRe
}
}
const siteId = params?.siteId || params?.siteSelector || 'root'
const siteId = optionalTrim(params?.siteId) || optionalTrim(params?.siteSelector) || 'root'
const pagesWithContent = []
const nextPageUrl = getGraphNextPageUrl(data)
@@ -262,8 +263,9 @@ export const readPageTool: ToolConfig<SharepointToolParams, SharepointReadPageRe
siteId,
})
const encodedSiteId = encodeURIComponent(siteId)
for (const pageInfo of data.value) {
const contentUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/pages/${pageInfo.id}/microsoft.graph.sitePage?$expand=canvasLayout`
const contentUrl = `https://graph.microsoft.com/v1.0/sites/${encodedSiteId}/pages/${encodeURIComponent(pageInfo.id)}/microsoft.graph.sitePage?$expand=canvasLayout`
try {
const contentResponse = await fetch(contentUrl, {
+97 -35
View File
@@ -24,40 +24,12 @@ export interface SharepointPage {
publishingState?: {
level: string
}
canvasLayout?: {
horizontalSections: Array<{
layout: string
id: string
emphasis: string
columns?: Array<{
id: string
width: number
webparts: Array<{
id: string
innerHtml: string
}>
}>
webparts?: Array<{
id: string
innerHtml: string
}>
}>
}
canvasLayout?: CanvasLayout
}
export interface SharepointPageContent {
content: string
canvasLayout?: {
horizontalSections: Array<{
layout: string
id: string
emphasis: string
webparts: Array<{
id: string
innerHtml: string
}>
}>
} | null
canvasLayout?: CanvasLayout | null
}
interface SharepointColumn {
@@ -132,9 +104,8 @@ export interface SharepointReadSiteResponse extends ToolResponse {
createdDateTime?: string
lastModifiedDateTime?: string
isPersonalSite?: boolean
root?: {
serverRelativeUrl: string
}
// Graph returns an empty object marker (not a URL) when this site is the root of its site collection
root?: Record<string, never>
siteCollection?: {
hostname: string
}
@@ -177,14 +148,15 @@ export interface SharepointToolParams {
listDisplayName?: string
listDescription?: string
listTemplate?: string
// Update List Item
// Update List Item / Delete List Item / Get List Item
itemId?: string
listItemFields?: Record<string, unknown>
// Upload File
// Upload File / Download File / Delete File / Get Drive Item
driveId?: string
folderPath?: string
fileName?: string
files?: UserFile[]
driveItemId?: string
}
export interface GraphApiResponse {
@@ -220,6 +192,8 @@ export interface CanvasLayout {
id?: string
emphasis?: string
columns?: Array<{
id?: string
width?: number
webparts?: Array<{
id?: string
innerHtml?: string
@@ -242,6 +216,14 @@ export type SharepointResponse =
| SharepointUpdateListItemResponse
| SharepointAddListItemResponse
| SharepointUploadFileResponse
| SharepointDeleteListItemResponse
| SharepointGetListItemResponse
| SharepointDeletePageResponse
| SharepointUpdatePageResponse
| SharepointPublishPageResponse
| SharepointDownloadFileResponse
| SharepointDeleteFileResponse
| SharepointGetDriveItemResponse
export interface SharepointGetListResponse extends ToolResponse {
output: {
@@ -307,3 +289,83 @@ export interface SharepointUploadFileResponse extends ToolResponse {
errors?: SharepointUploadError[]
}
}
export interface SharepointDeleteListItemResponse extends ToolResponse {
output: {
deleted: boolean
itemId: string
}
}
export interface SharepointGetListItemResponse extends ToolResponse {
output: {
item: {
id: string
fields?: Record<string, unknown>
}
}
}
export interface SharepointDeletePageResponse extends ToolResponse {
output: {
deleted: boolean
pageId: string
}
}
export interface SharepointUpdatePageResponse extends ToolResponse {
output: {
page: SharepointPage
}
}
export interface SharepointPublishPageResponse extends ToolResponse {
output: {
published: boolean
pageId: string
}
}
export interface SharepointDriveItem {
id: string
name: string
webUrl?: string
size?: number
createdDateTime?: string
lastModifiedDateTime?: string
file?: {
mimeType?: string
} | null
folder?: {
childCount?: number
} | null
parentReference?: {
id?: string
driveId?: string
path?: string
} | null
}
export interface SharepointDownloadFileResponse extends ToolResponse {
output: {
file: {
name: string
mimeType: string
data: Buffer | string
size: number
}
}
}
export interface SharepointDeleteFileResponse extends ToolResponse {
output: {
deleted: boolean
itemId: string
}
}
export interface SharepointGetDriveItemResponse extends ToolResponse {
output: {
driveItem: SharepointDriveItem
}
}
+3 -52
View File
@@ -1,13 +1,10 @@
import { createLogger } from '@sim/logger'
import type {
SharepointToolParams,
SharepointUpdateListItemResponse,
} from '@/tools/sharepoint/types'
import { optionalTrim } from '@/tools/sharepoint/utils'
import { optionalTrim, sanitizeListItemFields } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SharePointUpdateListItem')
export const updateListItemTool: ToolConfig<
SharepointToolParams,
SharepointUpdateListItemResponse
@@ -72,7 +69,7 @@ export const updateListItemTool: ToolConfig<
throw new Error('listId must be provided')
}
const listSegment = encodeURIComponent(listId)
return `https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${listSegment}/items/${encodeURIComponent(itemId)}/fields`
return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists/${listSegment}/items/${encodeURIComponent(itemId)}/fields`
},
method: 'PATCH',
headers: (params) => ({
@@ -85,53 +82,7 @@ export const updateListItemTool: ToolConfig<
throw new Error('listItemFields must not be empty')
}
// Filter out system/read-only fields that cannot be updated via Graph
const readOnlyFields = new Set<string>([
'Id',
'id',
'UniqueId',
'GUID',
'ContentTypeId',
'Created',
'Modified',
'Author',
'Editor',
'CreatedBy',
'ModifiedBy',
'AuthorId',
'EditorId',
'_UIVersionString',
'Attachments',
'FileRef',
'FileDirRef',
'FileLeafRef',
])
const entries = Object.entries(params.listItemFields)
const updatableEntries = entries.filter(([key]) => !readOnlyFields.has(key))
if (updatableEntries.length !== entries.length) {
const removed = entries.filter(([key]) => readOnlyFields.has(key)).map(([key]) => key)
logger.warn('Removed read-only SharePoint fields from update', {
removed,
})
}
if (updatableEntries.length === 0) {
const requestedKeys = Object.keys(params.listItemFields)
throw new Error(
`All provided fields are read-only and cannot be updated: ${requestedKeys.join(', ')}`
)
}
const sanitizedFields = Object.fromEntries(updatableEntries)
logger.info('Updating SharePoint list item fields', {
listItemId: params.itemId,
listId: params.listId,
fieldsKeys: Object.keys(sanitizedFields),
})
return sanitizedFields
return sanitizeListItemFields(params.listItemFields, { action: 'update' })
},
},
+168
View File
@@ -0,0 +1,168 @@
import { createLogger } from '@sim/logger'
import type {
CanvasLayout,
SharepointToolParams,
SharepointUpdatePageResponse,
} from '@/tools/sharepoint/types'
import { escapeHtml, optionalTrim } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SharePointUpdatePage')
export const updatePageTool: ToolConfig<SharepointToolParams, SharepointUpdatePageResponse> = {
id: 'sharepoint_update_page',
name: 'Update SharePoint Page',
description: 'Update the title and/or content of a SharePoint page',
version: '1.0.0',
oauth: {
required: true,
provider: 'sharepoint',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the SharePoint API',
},
siteId: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'The ID of the SharePoint site (internal use)',
},
siteSelector: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Select the SharePoint site',
},
pageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'The ID of the page to update. Example: a GUID like 12345678-1234-1234-1234-123456789012',
},
pageTitle: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'The new title of the page',
},
pageContent: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'The new text content of the page. Replaces the entire canvas layout of the page.',
},
},
request: {
url: (params) => {
const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
const pageId = optionalTrim(params.pageId)
if (!pageId) throw new Error('pageId must be provided')
return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageId)}/microsoft.graph.sitePage`
},
method: 'PATCH',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
Accept: 'application/json',
}),
body: (params) => {
const pageTitle = optionalTrim(params.pageTitle)
const pageContent = typeof params.pageContent === 'string' ? params.pageContent : undefined
if (!pageTitle && !pageContent) {
throw new Error('At least one of pageTitle or pageContent must be provided')
}
const pageData: {
'@odata.type': string
title?: string
canvasLayout?: CanvasLayout
} = {
'@odata.type': '#microsoft.graph.sitePage',
}
if (pageTitle) pageData.title = pageTitle
if (pageContent) {
pageData.canvasLayout = {
horizontalSections: [
{
layout: 'oneColumn',
id: '1',
emphasis: 'none',
columns: [
{
id: '1',
width: 12,
webparts: [
{
id: '6f9230af-2a98-4952-b205-9ede4f9ef548',
innerHtml: `<p>${escapeHtml(pageContent)}</p>`,
},
],
},
],
},
],
}
}
logger.info('Updating SharePoint page', {
pageId: params.pageId,
hasTitle: !!pageTitle,
hasContent: !!pageContent,
})
return pageData
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
logger.info('SharePoint page updated successfully', {
pageId: data.id,
pageName: data.name,
pageTitle: data.title,
})
return {
success: true,
output: {
page: {
id: data.id,
name: data.name,
title: data.title || data.name,
webUrl: data.webUrl,
pageLayout: data.pageLayout,
createdDateTime: data.createdDateTime,
lastModifiedDateTime: data.lastModifiedDateTime,
},
},
}
},
outputs: {
page: {
type: 'object',
description: 'Updated SharePoint page information',
properties: {
id: { type: 'string', description: 'The unique ID of the page' },
name: { type: 'string', description: 'The name of the page' },
title: { type: 'string', description: 'The title of the page' },
webUrl: { type: 'string', description: 'The URL to access the page' },
pageLayout: { type: 'string', description: 'The layout type of the page' },
createdDateTime: { type: 'string', description: 'When the page was created' },
lastModifiedDateTime: { type: 'string', description: 'When the page was last modified' },
},
},
},
}
+60
View File
@@ -13,6 +13,15 @@ export function escapeODataString(value: string): string {
return value.replace(/'/g, "''")
}
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
export function getGraphNextPageUrl(data: object): string | undefined {
const nextLink = (data as Record<string, unknown>)['@odata.nextLink']
return typeof nextLink === 'string' ? nextLink : undefined
@@ -102,6 +111,57 @@ export function extractTextFromCanvasLayout(canvasLayout: CanvasLayout | null |
return finalContent
}
/** SharePoint list item fields that are system-managed and cannot be set via the Graph API. */
export const READ_ONLY_LIST_ITEM_FIELDS = new Set<string>([
'Id',
'id',
'UniqueId',
'GUID',
'ContentTypeId',
'Created',
'Modified',
'Author',
'Editor',
'CreatedBy',
'ModifiedBy',
'AuthorId',
'EditorId',
'_UIVersionString',
'Attachments',
'FileRef',
'FileDirRef',
'FileLeafRef',
])
/**
* Removes read-only/system-managed fields from a SharePoint list item field set, logging any
* fields that were stripped. Throws if no updatable fields remain.
*/
export function sanitizeListItemFields(
fields: Record<string, unknown>,
context: { action: 'update' | 'create' }
): Record<string, unknown> {
const entries = Object.entries(fields)
const updatableEntries = entries.filter(([key]) => !READ_ONLY_LIST_ITEM_FIELDS.has(key))
if (updatableEntries.length !== entries.length) {
const removed = entries
.filter(([key]) => READ_ONLY_LIST_ITEM_FIELDS.has(key))
.map(([key]) => key)
logger.warn(`Removed read-only SharePoint fields from ${context.action}`, { removed })
}
if (updatableEntries.length === 0) {
const requestedKeys = Object.keys(fields)
const verb = context.action === 'update' ? 'updated' : 'set'
throw new Error(
`All provided fields are read-only and cannot be ${verb}: ${requestedKeys.join(', ')}`
)
}
return Object.fromEntries(updatableEntries)
}
export function cleanODataMetadata<T>(obj: T): T {
if (!obj || typeof obj !== 'object') return obj