feat(microsoft-excel): add SharePoint drive support for Excel integration (#4162)

* feat(microsoft-excel): add SharePoint drive support for Excel integration

* fix(microsoft-excel): address PR review comments

- Validate siteId/driveId format in drives route to prevent path traversal
- Use direct single-drive endpoint for fetchById instead of filtering full list
- Fix dependsOn on sheet/spreadsheet selectors so driveId flows into context
- Fix NextRequest type in drives route for build compatibility

* fix(microsoft-excel): validate driveId in files route

Add regex validation for driveId query param in the Microsoft OAuth
files route to prevent path traversal, matching the drives route.

* fix(microsoft-excel): unblock OneDrive users and validate driveId in sheets route

- Add credential to any[] arrays so OneDrive users (no drive selected)
  still pass the dependsOn gate while driveSelector remains in the
  dependency list for context flow to SharePoint users
- Add /^[\w-]+$/ validation for driveId in sheets API route

* fix(microsoft-excel): validate driveId in getItemBasePath utility

Add regex validation for driveId at the shared utility level to prevent
path traversal through the tool execution path, which bypasses the
API route validators.

* fix(microsoft-excel): use centralized input validation

Replace inline regex validation with platform validators from
@/lib/core/security/input-validation:
- validateSharePointSiteId for siteId in drives route
- validateAlphanumericId for driveId in drives, sheets, files routes
  and getItemBasePath utility

* lint

* improvement(microsoft-excel): add File Source dropdown to control SharePoint visibility

Replace always-visible optional SharePoint fields with a File Source
dropdown (OneDrive/SharePoint) that conditionally shows site and drive
selectors. OneDrive users see zero extra fields (default). SharePoint
users switch the dropdown and get the full cascade.

* fix(microsoft-excel): fix canonical param test failures

Make fileSource dropdown mode:'both' so it appears in basic and advanced
modes. Add condition to manualDriveId to match driveSelector's condition,
satisfying the canonical pair consistency test.

* fix(microsoft-excel): address PR review feedback for SharePoint drive support

- Clear stale driveId/siteId/spreadsheetId when fileSource changes by adding
  fileSource to dependsOn arrays for siteSelector, driveSelector, and
  spreadsheetId selectors
- Reorder manualDriveId before manualSpreadsheetId in advanced mode for
  logical top-down flow
- Validate spreadsheetId with validateMicrosoftGraphId in getItemBasePath()
  and sheets route to close injection vector (uses permissive validator that
  accepts ! chars in OneDrive item IDs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(microsoft-excel): use validateMicrosoftGraphId for driveId validation

SharePoint drive IDs use the format b!<base64-string> which contains !
characters rejected by validateAlphanumericId. Switch all driveId
validation to validateMicrosoftGraphId which blocks path traversal and
control characters while accepting valid Microsoft Graph identifiers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(microsoft-excel): use validatePathSegment with strict pattern for driveId/spreadsheetId

Replace validateMicrosoftGraphId with validatePathSegment using a custom
pattern ^[a-zA-Z0-9!_-]+$ for all URL-interpolated IDs. validatePathSegment
blocks /, \, path traversal, and null bytes before checking the pattern,
preventing URL-modifying characters like ?, #, & from altering the Graph
API endpoint. The pattern allows ! for SharePoint b!<base64> drive IDs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

* fix(microsoft-excel): reorder driveId before spreadsheetId in v1 block

Move driveId subBlock before manualSpreadsheetId in the legacy v1 block
to match the logical top-down flow (Drive ID → Spreadsheet ID), consistent
with the v2 block ordering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(microsoft-excel): clear manualDriveId when fileSource changes

Add dependsOn: ['fileSource'] to manualDriveId so its value is cleared
when switching from SharePoint back to OneDrive. Without this, the stale
driveId would still be serialized and forwarded to getItemBasePath,
routing through the SharePoint drive path instead of me/drive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(microsoft-excel): use getItemBasePath in sheets route to remove duplication

Replace inline URL construction and validation logic with the shared
getItemBasePath utility, eliminating duplicated GRAPH_ID_PATTERN regex
and conditional URL building.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-04-14 21:10:55 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 61b33e5978
commit 80095788fc
14 changed files with 462 additions and 118 deletions
@@ -45,6 +45,7 @@ Read data from a specific sheet in a Microsoft Excel spreadsheet
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet/workbook to read from \(e.g., "01ABC123DEF456"\) |
| `driveId` | string | No | The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive. |
| `range` | string | No | The range of cells to read from. Accepts "SheetName!A1:B2" for explicit ranges or just "SheetName" to read the used range of that sheet. If omitted, reads the used range of the first sheet. |
#### Output
@@ -67,6 +68,7 @@ Write data to a specific sheet in a Microsoft Excel spreadsheet
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet/workbook to write to \(e.g., "01ABC123DEF456"\) |
| `driveId` | string | No | The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive. |
| `range` | string | No | The range of cells to write to \(e.g., "Sheet1!A1:B2"\) |
| `values` | array | Yes | The data to write as a 2D array \(e.g., \[\["Name", "Age"\], \["Alice", 30\]\]\) or array of objects |
| `valueInputOption` | string | No | The format of the data to write |
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -19,6 +20,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const query = searchParams.get('query') || ''
const driveId = searchParams.get('driveId') || undefined
const workflowId = searchParams.get('workflowId') || undefined
if (!credentialId) {
@@ -72,8 +74,21 @@ export async function GET(request: NextRequest) {
)
searchParams_new.append('$top', '50')
// When driveId is provided (SharePoint), search within that specific drive.
// Otherwise, search the user's personal OneDrive.
if (driveId) {
const driveIdValidation = validatePathSegment(driveId, {
paramName: 'driveId',
customPattern: /^[a-zA-Z0-9!_-]+$/,
})
if (!driveIdValidation.isValid) {
return NextResponse.json({ error: driveIdValidation.error }, { status: 400 })
}
}
const drivePath = driveId ? `drives/${driveId}` : 'me/drive'
const response = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}`,
`https://graph.microsoft.com/v1.0/${drivePath}/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
@@ -0,0 +1,134 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validatePathSegment, validateSharePointSiteId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('MicrosoftExcelDrivesAPI')
interface GraphDrive {
id: string
name: string
driveType: string
webUrl?: string
}
/**
* List document libraries (drives) for a SharePoint site.
* Used by the microsoft.excel.drives selector to let users pick
* which drive contains their Excel file.
*/
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId, siteId, driveId } = body
if (!credential) {
logger.warn(`[${requestId}] Missing credential in request`)
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
if (!siteId) {
logger.warn(`[${requestId}] Missing siteId in request`)
return NextResponse.json({ error: 'Site ID is required' }, { status: 400 })
}
const siteIdValidation = validateSharePointSiteId(siteId, 'siteId')
if (!siteIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid siteId format`)
return NextResponse.json({ error: siteIdValidation.error }, { status: 400 })
}
const authz = await authorizeCredentialUse(request, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.warn(`[${requestId}] Failed to obtain valid access token`)
return NextResponse.json(
{ error: 'Failed to obtain valid access token', authRequired: true },
{ status: 401 }
)
}
// Single-drive lookup when driveId is provided (used by fetchById)
if (driveId) {
const driveIdValidation = validatePathSegment(driveId, {
paramName: 'driveId',
customPattern: /^[a-zA-Z0-9!_-]+$/,
})
if (!driveIdValidation.isValid) {
return NextResponse.json({ error: driveIdValidation.error }, { status: 400 })
}
const url = `https://graph.microsoft.com/v1.0/sites/${siteId}/drives/${driveId}?$select=id,name,driveType,webUrl`
const response = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ error: { message: 'Unknown error' } }))
return NextResponse.json(
{ error: errorData.error?.message || 'Failed to fetch drive' },
{ status: response.status }
)
}
const data: GraphDrive = await response.json()
return NextResponse.json(
{ drive: { id: data.id, name: data.name, driveType: data.driveType } },
{ status: 200 }
)
}
// List all drives for the site
const url = `https://graph.microsoft.com/v1.0/sites/${siteId}/drives?$select=id,name,driveType,webUrl`
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
logger.error(`[${requestId}] Microsoft Graph API error fetching drives`, {
status: response.status,
error: errorData.error?.message,
})
return NextResponse.json(
{ error: errorData.error?.message || 'Failed to fetch drives' },
{ status: response.status }
)
}
const data = await response.json()
const drives = (data.value || []).map((drive: GraphDrive) => ({
id: drive.id,
name: drive.name,
driveType: drive.driveType,
}))
logger.info(`[${requestId}] Successfully fetched ${drives.length} drives for site ${siteId}`)
return NextResponse.json({ drives }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching drives`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getItemBasePath } from '@/tools/microsoft_excel/utils'
export const dynamic = 'force-dynamic'
@@ -30,6 +31,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const spreadsheetId = searchParams.get('spreadsheetId')
const driveId = searchParams.get('driveId') || undefined
const workflowId = searchParams.get('workflowId') || undefined
if (!credentialId) {
@@ -61,17 +63,23 @@ export async function GET(request: NextRequest) {
`[${requestId}] Fetching worksheets from Microsoft Graph API for workbook ${spreadsheetId}`
)
// Fetch worksheets from Microsoft Graph API
const worksheetsResponse = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
}
)
let basePath: string
try {
basePath = getItemBasePath(spreadsheetId, driveId)
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Invalid parameters' },
{ status: 400 }
)
}
const worksheetsResponse = await fetch(`${basePath}/workbook/worksheets`, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
if (!worksheetsResponse.ok) {
const errorData = await worksheetsResponse
+96 -8
View File
@@ -68,6 +68,13 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
dependsOn: ['credential'],
mode: 'basic',
},
{
id: 'driveId',
title: 'Drive ID (SharePoint)',
type: 'short-input',
placeholder: 'Leave empty for OneDrive, or enter drive ID for SharePoint',
mode: 'advanced',
},
{
id: 'manualSpreadsheetId',
title: 'Spreadsheet ID',
@@ -249,9 +256,17 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
}
},
params: (params) => {
const { oauthCredential, values, spreadsheetId, tableName, worksheetName, ...rest } = params
const {
oauthCredential,
values,
spreadsheetId,
tableName,
worksheetName,
driveId,
siteId: _siteId,
...rest
} = params
// Use canonical param ID (raw subBlock IDs are deleted after serialization)
const effectiveSpreadsheetId = spreadsheetId ? String(spreadsheetId).trim() : ''
let parsedValues
@@ -276,6 +291,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
const baseParams = {
...rest,
spreadsheetId: effectiveSpreadsheetId,
driveId: driveId ? String(driveId).trim() : undefined,
values: parsedValues,
oauthCredential,
}
@@ -302,6 +318,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Microsoft Excel access token' },
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
driveId: { type: 'string', description: 'Drive ID for SharePoint document libraries' },
range: { type: 'string', description: 'Cell range' },
tableName: { type: 'string', description: 'Table name' },
worksheetName: { type: 'string', description: 'Worksheet name' },
@@ -377,6 +394,47 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
placeholder: 'Enter credential ID',
required: true,
},
// File Source selector (both modes)
{
id: 'fileSource',
title: 'File Source',
type: 'dropdown',
options: [
{ label: 'OneDrive', id: 'onedrive' },
{ label: 'SharePoint', id: 'sharepoint' },
],
value: () => 'onedrive',
},
// SharePoint Site Selector (basic mode, only when SharePoint is selected)
{
id: 'siteSelector',
title: 'SharePoint Site',
type: 'file-selector',
canonicalParamId: 'siteId',
serviceId: 'sharepoint',
selectorKey: 'sharepoint.sites',
requiredScopes: [],
placeholder: 'Select a SharePoint site',
dependsOn: ['credential', 'fileSource'],
condition: { field: 'fileSource', value: 'sharepoint' },
required: { field: 'fileSource', value: 'sharepoint' },
mode: 'basic',
},
// SharePoint Drive Selector (basic mode, only when SharePoint is selected)
{
id: 'driveSelector',
title: 'Document Library',
type: 'file-selector',
canonicalParamId: 'driveId',
serviceId: 'microsoft-excel',
selectorKey: 'microsoft.excel.drives',
selectorAllowSearch: false,
placeholder: 'Select a document library',
dependsOn: ['credential', 'siteSelector', 'fileSource'],
condition: { field: 'fileSource', value: 'sharepoint' },
required: { field: 'fileSource', value: 'sharepoint' },
mode: 'basic',
},
// Spreadsheet Selector (basic mode)
{
id: 'spreadsheetId',
@@ -388,9 +446,20 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
requiredScopes: [],
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
placeholder: 'Select a spreadsheet',
dependsOn: ['credential'],
dependsOn: { all: ['credential', 'fileSource'], any: ['credential', 'driveSelector'] },
mode: 'basic',
},
// Drive ID for SharePoint (advanced mode, only when SharePoint is selected)
{
id: 'manualDriveId',
title: 'Drive ID',
type: 'short-input',
canonicalParamId: 'driveId',
placeholder: 'Enter the SharePoint drive ID',
condition: { field: 'fileSource', value: 'sharepoint' },
dependsOn: ['fileSource'],
mode: 'advanced',
},
// Manual Spreadsheet ID (advanced mode)
{
id: 'manualSpreadsheetId',
@@ -398,7 +467,7 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
type: 'short-input',
canonicalParamId: 'spreadsheetId',
placeholder: 'Enter spreadsheet ID',
dependsOn: ['credential'],
dependsOn: { all: ['credential'], any: ['credential', 'manualDriveId'] },
mode: 'advanced',
},
// Sheet Name Selector (basic mode)
@@ -412,7 +481,10 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
selectorAllowSearch: false,
placeholder: 'Select a sheet',
required: true,
dependsOn: { all: ['credential'], any: ['spreadsheetId', 'manualSpreadsheetId'] },
dependsOn: {
all: ['credential'],
any: ['spreadsheetId', 'manualSpreadsheetId', 'driveSelector'],
},
mode: 'basic',
},
// Manual Sheet Name (advanced mode)
@@ -423,7 +495,10 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
canonicalParamId: 'sheetName',
placeholder: 'Name of the sheet/tab (e.g., Sheet1)',
required: true,
dependsOn: ['credential'],
dependsOn: {
all: ['credential'],
any: ['credential', 'manualDriveId'],
},
mode: 'advanced',
},
// Cell Range (optional for read/write)
@@ -514,11 +589,20 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
fallbackToolId: 'microsoft_excel_read_v2',
}),
params: (params) => {
const { oauthCredential, values, spreadsheetId, sheetName, cellRange, ...rest } = params
const {
oauthCredential,
values,
spreadsheetId,
sheetName,
cellRange,
driveId,
siteId: _siteId,
fileSource: _fileSource,
...rest
} = params
const parsedValues = values ? JSON.parse(values as string) : undefined
// Use canonical param IDs (raw subBlock IDs are deleted after serialization)
const effectiveSpreadsheetId = spreadsheetId ? String(spreadsheetId).trim() : ''
const effectiveSheetName = sheetName ? String(sheetName).trim() : ''
@@ -535,6 +619,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
spreadsheetId: effectiveSpreadsheetId,
sheetName: effectiveSheetName,
cellRange: cellRange ? (cellRange as string).trim() : undefined,
driveId: driveId ? String(driveId).trim() : undefined,
values: parsedValues,
oauthCredential,
}
@@ -543,7 +628,10 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
fileSource: { type: 'string', description: 'File source (onedrive or sharepoint)' },
oauthCredential: { type: 'string', description: 'Microsoft Excel access token' },
siteId: { type: 'string', description: 'SharePoint site ID (used for drive/file browsing)' },
driveId: { type: 'string', description: 'Drive ID for SharePoint document libraries' },
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
sheetName: { type: 'string', description: 'Name of the sheet/tab (canonical param)' },
cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' },
+52
View File
@@ -1504,6 +1504,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
'microsoft.excel.sheets',
context.oauthCredential ?? 'none',
context.spreadsheetId ?? 'none',
context.driveId ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.spreadsheetId),
fetchList: async ({ context }: SelectorQueryArgs) => {
@@ -1517,6 +1518,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
searchParams: {
credentialId,
spreadsheetId: context.spreadsheetId,
driveId: context.driveId,
workflowId: context.workflowId,
},
}
@@ -1527,6 +1529,54 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
}))
},
},
'microsoft.excel.drives': {
key: 'microsoft.excel.drives',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.excel.drives',
context.oauthCredential ?? 'none',
context.siteId ?? 'none',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.siteId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.excel.drives')
if (!context.siteId) {
throw new Error('Missing site ID for microsoft.excel.drives selector')
}
const body = JSON.stringify({
credential: credentialId,
workflowId: context.workflowId,
siteId: context.siteId,
})
const data = await fetchJson<{ drives: { id: string; name: string }[] }>(
'/api/tools/microsoft_excel/drives',
{ method: 'POST', body }
)
return (data.drives || []).map((drive) => ({
id: drive.id,
label: drive.name,
}))
},
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
if (!detailId || !context.siteId) return null
const credentialId = ensureCredential(context, 'microsoft.excel.drives')
const data = await fetchJson<{ drive: { id: string; name: string } }>(
'/api/tools/microsoft_excel/drives',
{
method: 'POST',
body: JSON.stringify({
credential: credentialId,
workflowId: context.workflowId,
siteId: context.siteId,
driveId: detailId,
}),
}
)
if (!data.drive) return null
return { id: data.drive.id, label: data.drive.name }
},
},
'microsoft.excel': {
key: 'microsoft.excel',
staleTime: SELECTOR_STALE,
@@ -1534,6 +1584,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
'selectors',
'microsoft.excel',
context.oauthCredential ?? 'none',
context.driveId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.oauthCredential),
@@ -1545,6 +1596,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
searchParams: {
credentialId,
query: search,
driveId: context.driveId,
workflowId: context.workflowId,
},
}
+2
View File
@@ -40,6 +40,7 @@ export type SelectorKey =
| 'onedrive.folders'
| 'sharepoint.sites'
| 'microsoft.excel'
| 'microsoft.excel.drives'
| 'microsoft.excel.sheets'
| 'microsoft.word'
| 'microsoft.planner'
@@ -75,6 +76,7 @@ export interface SelectorContext {
siteId?: string
collectionId?: string
spreadsheetId?: string
driveId?: string
excludeWorkflowId?: string
baseId?: string
datasetId?: string
@@ -17,6 +17,7 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
'siteId',
'collectionId',
'spreadsheetId',
'driveId',
'fileId',
'baseId',
'datasetId',
+37 -43
View File
@@ -6,6 +6,7 @@ import type {
MicrosoftExcelV2ToolParams,
} from '@/tools/microsoft_excel/types'
import {
getItemBasePath,
getSpreadsheetWebUrl,
trimTrailingEmptyRowsAndColumns,
} from '@/tools/microsoft_excel/utils'
@@ -35,6 +36,13 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
visibility: 'user-or-llm',
description: 'The ID of the spreadsheet/workbook to read from (e.g., "01ABC123DEF456")',
},
driveId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive.',
},
range: {
type: 'string',
required: false,
@@ -51,18 +59,17 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
throw new Error('Spreadsheet ID is required')
}
const basePath = getItemBasePath(spreadsheetId, params.driveId)
if (!params.range) {
// When no range is provided, first fetch the first worksheet name (to avoid hardcoding "Sheet1")
// We'll read its default range after in transformResponse
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets?$select=name&$orderby=position&$top=1`
return `${basePath}/workbook/worksheets?$select=name&$orderby=position&$top=1`
}
const rangeInput = params.range.trim()
// If the input contains no '!', treat it as a sheet name only and fetch usedRange
if (!rangeInput.includes('!')) {
const sheetOnly = encodeURIComponent(rangeInput)
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets('${sheetOnly}')/usedRange(valuesOnly=true)`
return `${basePath}/workbook/worksheets('${sheetOnly}')/usedRange(valuesOnly=true)`
}
const match = rangeInput.match(/^([^!]+)!(.+)$/)
@@ -76,7 +83,7 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
const sheetName = encodeURIComponent(match[1])
const address = encodeURIComponent(match[2])
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets('${sheetName}')/range(address='${address}')`
return `${basePath}/workbook/worksheets('${sheetName}')/range(address='${address}')`
},
method: 'GET',
headers: (params) => {
@@ -91,6 +98,9 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
},
transformResponse: async (response: Response, params?: MicrosoftExcelToolParams) => {
const spreadsheetId = params?.spreadsheetId?.trim() || ''
const driveId = params?.driveId
// If we came from the worksheets listing (no range provided), resolve first sheet name then fetch range
if (response.url.includes('/workbook/worksheets?')) {
const listData = await response.json()
@@ -100,23 +110,19 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
throw new Error('No worksheets found in the Excel workbook')
}
const spreadsheetIdFromUrl = response.url.split('/drive/items/')[1]?.split('/')[0] || ''
const accessToken = params?.accessToken
if (!accessToken) {
throw new Error('Access token is required to read Excel range')
}
// Use usedRange(valuesOnly=true) to fetch only populated cells, avoiding thousands of empty rows
const rangeUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${encodeURIComponent(
spreadsheetIdFromUrl
)}/workbook/worksheets('${encodeURIComponent(firstSheetName)}')/usedRange(valuesOnly=true)`
const basePath = getItemBasePath(spreadsheetId, driveId)
const rangeUrl = `${basePath}/workbook/worksheets('${encodeURIComponent(firstSheetName)}')/usedRange(valuesOnly=true)`
const rangeResp = await fetch(rangeUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!rangeResp.ok) {
// Normalize Microsoft Graph sheet/range errors to a friendly message
throw new Error(
'Invalid range provided or worksheet not found. Provide a range like "Sheet1!A1:B2" or just the sheet name to read the whole sheet'
)
@@ -124,20 +130,12 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
const data = await rangeResp.json()
// usedRange returns an address (A1 notation) and values matrix
const address: string = data.address || data.addressLocal || `${firstSheetName}!A1`
const rawValues: ExcelCellValue[][] = data.values || []
const values = trimTrailingEmptyRowsAndColumns(rawValues)
// Fetch the browser-accessible web URL
const webUrl = await getSpreadsheetWebUrl(spreadsheetIdFromUrl, accessToken)
const metadata = {
spreadsheetId: spreadsheetIdFromUrl,
properties: {},
spreadsheetUrl: webUrl,
}
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
const result: MicrosoftExcelReadResponse = {
success: true,
@@ -147,8 +145,8 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
values,
},
metadata: {
spreadsheetId: metadata.spreadsheetId,
spreadsheetUrl: metadata.spreadsheetUrl,
spreadsheetId,
spreadsheetUrl: webUrl,
},
},
}
@@ -159,21 +157,11 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
// Normal path: caller supplied a range; just return the parsed result
const data = await response.json()
const urlParts = response.url.split('/drive/items/')
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
// Fetch the browser-accessible web URL
const accessToken = params?.accessToken
if (!accessToken) {
throw new Error('Access token is required')
}
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken)
const metadata = {
spreadsheetId,
properties: {},
spreadsheetUrl: webUrl,
}
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
const address: string = data.address || data.addressLocal || data.range || ''
const rawValues: ExcelCellValue[][] = data.values || []
@@ -187,8 +175,8 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
values,
},
metadata: {
spreadsheetId: metadata.spreadsheetId,
spreadsheetUrl: metadata.spreadsheetUrl,
spreadsheetId,
spreadsheetUrl: webUrl,
},
},
}
@@ -240,6 +228,13 @@ export const readV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV2
visibility: 'user-or-llm',
description: 'The ID of the spreadsheet/workbook to read from (e.g., "01ABC123DEF456")',
},
driveId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive.',
},
sheetName: {
type: 'string',
required: true,
@@ -267,17 +262,17 @@ export const readV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV2
throw new Error('Sheet name is required')
}
const basePath = getItemBasePath(spreadsheetId, params.driveId)
const encodedSheetName = encodeURIComponent(sheetName)
// If no cell range specified, fetch usedRange
if (!params.cellRange) {
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets('${encodedSheetName}')/usedRange(valuesOnly=true)`
return `${basePath}/workbook/worksheets('${encodedSheetName}')/usedRange(valuesOnly=true)`
}
const cellRange = params.cellRange.trim()
const encodedAddress = encodeURIComponent(cellRange)
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets('${encodedSheetName}')/range(address='${encodedAddress}')`
return `${basePath}/workbook/worksheets('${encodedSheetName}')/range(address='${encodedAddress}')`
},
method: 'GET',
headers: (params) => {
@@ -294,20 +289,19 @@ export const readV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV2
transformResponse: async (response: Response, params?: MicrosoftExcelV2ToolParams) => {
const data = await response.json()
const urlParts = response.url.split('/drive/items/')
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
const spreadsheetId = params?.spreadsheetId?.trim() || ''
const driveId = params?.driveId
const accessToken = params?.accessToken
if (!accessToken) {
throw new Error('Access token is required')
}
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken)
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
const address: string = data.address || data.addressLocal || ''
const rawValues: ExcelCellValue[][] = data.values || []
const values = trimTrailingEmptyRowsAndColumns(rawValues)
// Extract sheet name from address (format: SheetName!A1:B2)
const sheetName = params?.sheetName || address.split('!')[0] || ''
return {
+16 -16
View File
@@ -2,7 +2,7 @@ import type {
MicrosoftExcelTableAddResponse,
MicrosoftExcelTableToolParams,
} from '@/tools/microsoft_excel/types'
import { getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
import { getItemBasePath, getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
import type { ToolConfig } from '@/tools/types'
export const tableAddTool: ToolConfig<
@@ -33,6 +33,13 @@ export const tableAddTool: ToolConfig<
description:
'The ID of the spreadsheet/workbook containing the table (e.g., "01ABC123DEF456")',
},
driveId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive.',
},
tableName: {
type: 'string',
required: true,
@@ -51,7 +58,8 @@ export const tableAddTool: ToolConfig<
request: {
url: (params) => {
const tableName = encodeURIComponent(params.tableName)
return `https://graph.microsoft.com/v1.0/me/drive/items/${params.spreadsheetId}/workbook/tables('${tableName}')/rows/add`
const basePath = getItemBasePath(params.spreadsheetId, params.driveId)
return `${basePath}/workbook/tables('${tableName}')/rows/add`
},
method: 'POST',
headers: (params) => ({
@@ -106,34 +114,26 @@ export const tableAddTool: ToolConfig<
transformResponse: async (response: Response, params?: MicrosoftExcelTableToolParams) => {
const data = await response.json()
const urlParts = response.url.split('/drive/items/')
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
const spreadsheetId = params?.spreadsheetId?.trim() || ''
const driveId = params?.driveId
// Fetch the browser-accessible web URL
const accessToken = params?.accessToken
if (!accessToken) {
throw new Error('Access token is required')
}
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken)
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
const metadata = {
spreadsheetId,
spreadsheetUrl: webUrl,
}
const result = {
return {
success: true,
output: {
index: data.index || 0,
values: data.values || [],
metadata: {
spreadsheetId: metadata.spreadsheetId,
spreadsheetUrl: metadata.spreadsheetUrl,
spreadsheetId,
spreadsheetUrl: webUrl,
},
},
}
return result
},
outputs: {
+4
View File
@@ -63,6 +63,7 @@ export interface MicrosoftExcelWorksheetAddResponse extends ToolResponse {
export interface MicrosoftExcelToolParams {
accessToken: string
spreadsheetId: string
driveId?: string
range?: string
values?: ExcelCellValue[][]
valueInputOption?: 'RAW' | 'USER_ENTERED'
@@ -75,6 +76,7 @@ export interface MicrosoftExcelToolParams {
export interface MicrosoftExcelTableToolParams {
accessToken: string
spreadsheetId: string
driveId?: string
tableName: string
values: ExcelCellValue[][]
rowIndex?: number
@@ -83,6 +85,7 @@ export interface MicrosoftExcelTableToolParams {
export interface MicrosoftExcelWorksheetToolParams {
accessToken: string
spreadsheetId: string
driveId?: string
worksheetName: string
}
@@ -96,6 +99,7 @@ export type MicrosoftExcelResponse =
export interface MicrosoftExcelV2ToolParams {
accessToken: string
spreadsheetId: string
driveId?: string
sheetName: string
cellRange?: string
values?: ExcelCellValue[][]
+42 -12
View File
@@ -1,8 +1,39 @@
import { createLogger } from '@sim/logger'
import { validatePathSegment } from '@/lib/core/security/input-validation'
import type { ExcelCellValue } from '@/tools/microsoft_excel/types'
const logger = createLogger('MicrosoftExcelUtils')
/**
* Returns the Graph API base path for an Excel item.
* When driveId is provided, uses /drives/{driveId}/items/{itemId} (SharePoint/shared drives).
* When driveId is omitted, uses /me/drive/items/{itemId} (personal OneDrive).
*/
/** Pattern for Microsoft Graph item/drive IDs: alphanumeric, hyphens, underscores, and ! (for SharePoint b!<base64> format) */
const GRAPH_ID_PATTERN = /^[a-zA-Z0-9!_-]+$/
export function getItemBasePath(spreadsheetId: string, driveId?: string): string {
const spreadsheetValidation = validatePathSegment(spreadsheetId, {
paramName: 'spreadsheetId',
customPattern: GRAPH_ID_PATTERN,
})
if (!spreadsheetValidation.isValid) {
throw new Error(spreadsheetValidation.error)
}
if (driveId) {
const driveValidation = validatePathSegment(driveId, {
paramName: 'driveId',
customPattern: GRAPH_ID_PATTERN,
})
if (!driveValidation.isValid) {
throw new Error(driveValidation.error)
}
return `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${spreadsheetId}`
}
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}`
}
export function trimTrailingEmptyRowsAndColumns(matrix: ExcelCellValue[][]): ExcelCellValue[][] {
if (!Array.isArray(matrix) || matrix.length === 0) return []
@@ -43,33 +74,32 @@ export function trimTrailingEmptyRowsAndColumns(matrix: ExcelCellValue[][]): Exc
*/
export async function getSpreadsheetWebUrl(
spreadsheetId: string,
accessToken: string
accessToken: string,
driveId?: string
): Promise<string> {
const basePath = getItemBasePath(spreadsheetId, driveId)
try {
const response = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}?$select=id,webUrl`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
)
const response = await fetch(`${basePath}?$select=id,webUrl`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
logger.warn('Failed to fetch spreadsheet webUrl, using Graph API URL as fallback', {
spreadsheetId,
status: response.status,
})
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}`
return basePath
}
const data = await response.json()
return data.webUrl || `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}`
return data.webUrl || basePath
} catch (error) {
logger.warn('Error fetching spreadsheet webUrl, using Graph API URL as fallback', {
spreadsheetId,
error,
})
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}`
return basePath
}
}
@@ -2,7 +2,7 @@ import type {
MicrosoftExcelWorksheetAddResponse,
MicrosoftExcelWorksheetToolParams,
} from '@/tools/microsoft_excel/types'
import { getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
import { getItemBasePath, getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
import type { ToolConfig } from '@/tools/types'
/**
@@ -36,6 +36,13 @@ export const worksheetAddTool: ToolConfig<
visibility: 'user-or-llm',
description: 'The ID of the Excel workbook to add the worksheet to (e.g., "01ABC123DEF456")',
},
driveId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive.',
},
worksheetName: {
type: 'string',
required: true,
@@ -51,7 +58,8 @@ export const worksheetAddTool: ToolConfig<
if (!spreadsheetId) {
throw new Error('Spreadsheet ID is required')
}
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets/add`
const basePath = getItemBasePath(spreadsheetId, params.driveId)
return `${basePath}/workbook/worksheets/add`
},
method: 'POST',
headers: (params) => {
@@ -106,15 +114,14 @@ export const worksheetAddTool: ToolConfig<
const data = await response.json()
const urlParts = response.url.split('/drive/items/')
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
const spreadsheetId = params?.spreadsheetId?.trim() || ''
const driveId = params?.driveId
// Fetch the browser-accessible web URL
const accessToken = params?.accessToken
if (!accessToken) {
throw new Error('Access token is required')
}
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken)
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
const result: MicrosoftExcelWorksheetAddResponse = {
success: true,
+28 -21
View File
@@ -4,7 +4,7 @@ import type {
MicrosoftExcelV2WriteResponse,
MicrosoftExcelWriteResponse,
} from '@/tools/microsoft_excel/types'
import { getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
import { getItemBasePath, getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
import type { ToolConfig } from '@/tools/types'
export const writeTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelWriteResponse> = {
@@ -31,6 +31,13 @@ export const writeTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelWrite
visibility: 'user-or-llm',
description: 'The ID of the spreadsheet/workbook to write to (e.g., "01ABC123DEF456")',
},
driveId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive.',
},
range: {
type: 'string',
required: false,
@@ -70,8 +77,9 @@ export const writeTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelWrite
const sheetName = encodeURIComponent(match[1])
const address = encodeURIComponent(match[2])
const basePath = getItemBasePath(params.spreadsheetId!, params.driveId)
const url = new URL(
`https://graph.microsoft.com/v1.0/me/drive/items/${params.spreadsheetId}/workbook/worksheets('${sheetName}')/range(address='${address}')`
`${basePath}/workbook/worksheets('${sheetName}')/range(address='${address}')`
)
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
@@ -137,23 +145,16 @@ export const writeTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelWrite
transformResponse: async (response: Response, params?: MicrosoftExcelToolParams) => {
const data = await response.json()
const urlParts = response.url.split('/drive/items/')
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
const spreadsheetId = params?.spreadsheetId?.trim() || ''
const driveId = params?.driveId
// Fetch the browser-accessible web URL
const accessToken = params?.accessToken
if (!accessToken) {
throw new Error('Access token is required')
}
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken)
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
const metadata = {
spreadsheetId,
properties: {},
spreadsheetUrl: webUrl,
}
const result = {
return {
success: true,
output: {
updatedRange: data.updatedRange,
@@ -161,13 +162,11 @@ export const writeTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelWrite
updatedColumns: data.updatedColumns,
updatedCells: data.updatedCells,
metadata: {
spreadsheetId: metadata.spreadsheetId,
spreadsheetUrl: metadata.spreadsheetUrl,
spreadsheetId,
spreadsheetUrl: webUrl,
},
},
}
return result
},
outputs: {
@@ -210,6 +209,13 @@ export const writeV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV
visibility: 'user-or-llm',
description: 'The ID of the spreadsheet/workbook to write to (e.g., "01ABC123DEF456")',
},
driveId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive.',
},
sheetName: {
type: 'string',
required: true,
@@ -260,8 +266,9 @@ export const writeV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV
const encodedSheetName = encodeURIComponent(sheetName)
const encodedAddress = encodeURIComponent(cellRange)
const basePath = getItemBasePath(spreadsheetId, params.driveId)
const url = new URL(
`https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets('${encodedSheetName}')/range(address='${encodedAddress}')`
`${basePath}/workbook/worksheets('${encodedSheetName}')/range(address='${encodedAddress}')`
)
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
@@ -324,14 +331,14 @@ export const writeV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV
transformResponse: async (response: Response, params?: MicrosoftExcelV2ToolParams) => {
const data = await response.json()
const urlParts = response.url.split('/drive/items/')
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
const spreadsheetId = params?.spreadsheetId?.trim() || ''
const driveId = params?.driveId
const accessToken = params?.accessToken
if (!accessToken) {
throw new Error('Access token is required')
}
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken)
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
return {
success: true,