From 8b7b89f86f473163f7be3060f25160e4a4183ad4 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 24 Jun 2026 15:06:58 +0200 Subject: [PATCH 1/4] Fix Confluence integration failures --- bun.lock | 2 +- integrations/bitbucket/package.json | 2 +- integrations/bitbucket/src/lib/client.ts | 75 +++++++++++++++++-- .../src/tools/search-content.test.ts | 14 ++++ 4 files changed, 84 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index 54e6aad1ea..1e31d8cd71 100644 --- a/bun.lock +++ b/bun.lock @@ -1478,7 +1478,7 @@ }, "integrations/bitbucket": { "name": "@slates-integrations/bitbucket", - "version": "0.2.0-rc.12", + "version": "0.2.0-rc.13", "dependencies": { "@lowerdeck/error": "^1.1.0", "@slates/provider": "1.0.0-rc.15", diff --git a/integrations/bitbucket/package.json b/integrations/bitbucket/package.json index f9448221c7..27e371dfde 100644 --- a/integrations/bitbucket/package.json +++ b/integrations/bitbucket/package.json @@ -15,5 +15,5 @@ "devDependencies": { "typescript": "^5" }, - "version": "0.2.0-rc.12" + "version": "0.2.0-rc.13" } diff --git a/integrations/bitbucket/src/lib/client.ts b/integrations/bitbucket/src/lib/client.ts index ff63a4d052..bd87211387 100644 --- a/integrations/bitbucket/src/lib/client.ts +++ b/integrations/bitbucket/src/lib/client.ts @@ -1,6 +1,25 @@ import { createAxios } from '@slates/provider'; import { bitbucketApiError } from './errors'; +type BitbucketRef = { + name?: unknown; + target?: { + hash?: unknown; + }; +}; + +type BitbucketRefPage = { + values?: BitbucketRef[]; +}; + +let encodePathSegment = (value: string) => encodeURIComponent(value); + +let encodeSourcePath = (path: string) => + path.split('/').filter(Boolean).map(encodePathSegment).join('/'); + +let bitbucketStringLiteral = (value: string) => + `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + export class Client { private api: ReturnType; @@ -515,20 +534,62 @@ export class Client { // ─── Source / File Browsing ─── async getSource(repoSlug: string, opts: { revision: string; path: string }) { - let response = await this.api.get( - `/repositories/${this.params.workspace}/${repoSlug}/src/${opts.revision}/${opts.path}` - ); + let response = await this.api.get(await this.buildSourcePath(repoSlug, opts)); return response.data; } async getFileContent(repoSlug: string, opts: { revision: string; path: string }) { - let response = await this.api.get( - `/repositories/${this.params.workspace}/${repoSlug}/src/${opts.revision}/${opts.path}`, + let response = await this.api.get(await this.buildSourcePath(repoSlug, opts), { + headers: { Accept: 'application/json' } + }); + return response.data; + } + + private getRepositoryPath(repoSlug: string) { + return `/repositories/${encodePathSegment(this.params.workspace)}/${encodePathSegment(repoSlug)}`; + } + + private async buildSourcePath(repoSlug: string, opts: { revision: string; path: string }) { + let revision = await this.resolveSourceRevision(repoSlug, opts.revision); + let sourcePath = encodeSourcePath(opts.path); + let repositoryPath = this.getRepositoryPath(repoSlug); + + // Root source listings require a trailing slash. Non-root paths may be files. + if (!sourcePath) { + return `${repositoryPath}/src/${encodePathSegment(revision)}/`; + } + + return `${repositoryPath}/src/${encodePathSegment(revision)}/${sourcePath}`; + } + + private async resolveSourceRevision(repoSlug: string, revision: string) { + let branchHash = await this.findRefCommitHash(repoSlug, 'branches', revision); + if (branchHash) { + return branchHash; + } + + let tagHash = await this.findRefCommitHash(repoSlug, 'tags', revision); + return tagHash ?? revision; + } + + private async findRefCommitHash( + repoSlug: string, + refType: 'branches' | 'tags', + refName: string + ) { + let response = await this.api.get( + `${this.getRepositoryPath(repoSlug)}/refs/${refType}`, { - headers: { Accept: 'application/json' } + params: { + q: `name = ${bitbucketStringLiteral(refName)}`, + pagelen: '2' + } } ); - return response.data; + + let matchingRef = response.data.values?.find(item => item.name === refName); + let hash = matchingRef?.target?.hash; + return typeof hash === 'string' && hash.length > 0 ? hash : undefined; } // ─── Diff ─── diff --git a/integrations/confluence/src/tools/search-content.test.ts b/integrations/confluence/src/tools/search-content.test.ts index 318ab5aa98..17259a71ef 100644 --- a/integrations/confluence/src/tools/search-content.test.ts +++ b/integrations/confluence/src/tools/search-content.test.ts @@ -1,5 +1,6 @@ import { ServiceError } from '@lowerdeck/error'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; let confluenceClientMocks = vi.hoisted(() => ({ search: vi.fn() @@ -73,6 +74,19 @@ describe('Confluence search content', () => { ); }); + it('exports an MCP-compatible input schema for query and CQL searches', () => { + let jsonSchema = z.toJSONSchema(searchContent.inputSchema) as Record; + + expect(jsonSchema.type).toBe('object'); + expect(jsonSchema.properties.query.type).toBe('string'); + expect(jsonSchema.properties.cql.type).toBe('string'); + expect(jsonSchema.required ?? []).not.toContain('query'); + expect(jsonSchema.required ?? []).not.toContain('cql'); + expect(jsonSchema.oneOf).toBeUndefined(); + expect(jsonSchema.anyOf).toBeUndefined(); + expect(jsonSchema.allOf).toBeUndefined(); + }); + it('rejects missing and conflicting search inputs with ServiceError', () => { expect(() => resolveSearchContentQuery({})).toThrow(ServiceError); expect(() => From 0d986776c8a38e7fbdf07010a1863df4efe74723 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 24 Jun 2026 15:55:11 +0200 Subject: [PATCH 2/4] Fix Google Drive integration failures --- bun.lock | 10 +- integrations/elasticsearch/package.json | 9 +- integrations/elasticsearch/src/lib/errors.ts | 107 +++-------- .../elasticsearch/src/tools.schema.test.ts | 4 + .../elasticsearch/src/tools/get-document.ts | 53 +++--- .../src/tools/search-documents.ts | 2 +- integrations/elasticsearch/vitest.config.ts | 7 + integrations/google-drive/package.json | 2 +- .../google-drive/src/lib/client.test.ts | 168 ++++++++++++++++++ integrations/google-drive/src/lib/client.ts | 89 ++++++++-- .../src/provider.contract.test.ts | 8 +- .../google-drive/src/tools/search-files.ts | 18 +- 12 files changed, 338 insertions(+), 139 deletions(-) create mode 100644 integrations/elasticsearch/src/tools.schema.test.ts create mode 100644 integrations/elasticsearch/vitest.config.ts create mode 100644 integrations/google-drive/src/lib/client.test.ts diff --git a/bun.lock b/bun.lock index 1e31d8cd71..4d2527b289 100644 --- a/bun.lock +++ b/bun.lock @@ -4059,15 +4059,17 @@ }, "integrations/elasticsearch": { "name": "@slates-integrations/elasticsearch", - "version": "0.2.0-rc.6", + "version": "0.2.0-rc.7", "dependencies": { "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { + "@slates/test": "1.0.0-rc.9", "typescript": "^5", + "vitest": "^3.1.2", }, }, "integrations/eleven-labs": { @@ -18614,8 +18616,6 @@ "@slates-integrations/egnyte/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/elasticsearch/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/elevenreader/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/elorus/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -20684,8 +20684,6 @@ "@slates-integrations/egnyte/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/elasticsearch/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/elevenreader/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/elorus/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], diff --git a/integrations/elasticsearch/package.json b/integrations/elasticsearch/package.json index 8f910a6f11..0d4f42b033 100644 --- a/integrations/elasticsearch/package.json +++ b/integrations/elasticsearch/package.json @@ -4,16 +4,19 @@ "type": "module", "scripts": { "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s", + "test": "vitest run --config vitest.config.ts --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { "@lowerdeck/error": "^1.1.0", "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": { - "typescript": "^5" + "@slates/test": "1.0.0-rc.9", + "typescript": "^5", + "vitest": "^3.1.2" }, - "version": "0.2.0-rc.6" + "version": "0.2.0-rc.7" } diff --git a/integrations/elasticsearch/src/lib/errors.ts b/integrations/elasticsearch/src/lib/errors.ts index fac03f5ba7..ddd1c7cd2c 100644 --- a/integrations/elasticsearch/src/lib/errors.ts +++ b/integrations/elasticsearch/src/lib/errors.ts @@ -1,88 +1,23 @@ -import { badRequestError, ServiceError } from '@lowerdeck/error'; - -type ErrorResponse = { - status?: number; - statusText?: string; - data?: unknown; -}; - -let isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null; - -let addMessage = (messages: string[], value: unknown) => { - if (typeof value !== 'string') return; - - let trimmed = value.trim(); - if (trimmed && !messages.includes(trimmed)) { - messages.push(trimmed); - } -}; - -let collectMessages = (value: unknown, messages: string[]) => { - if (typeof value === 'string') { - addMessage(messages, value); - return; - } - - if (Array.isArray(value)) { - for (let item of value) { - collectMessages(item, messages); - } - return; - } - - if (!isRecord(value)) return; - - for (let key of ['reason', 'message', 'type', 'error', 'status']) { - addMessage(messages, value[key]); - } - - collectMessages(value.root_cause, messages); - collectMessages(value.caused_by, messages); - collectMessages(value.errors, messages); -}; - -let extractElasticsearchMessage = (error: unknown) => { - let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; - let messages: string[] = []; - - collectMessages(response?.data, messages); - - if (messages.length > 0) { - return messages.join(' - '); - } - - if (error instanceof Error && error.message) { - return error.message; - } - - return 'Unknown error'; -}; +import { buildApiServiceError, createApiServiceError } from 'slates'; export let elasticsearchServiceError = (message: string) => - new ServiceError(badRequestError({ message })); - -export let elasticsearchApiError = (error: unknown, operation = 'request') => { - if (error instanceof ServiceError) { - return error; - } - - let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined; - let status = response?.status; - let statusLabel = - status !== undefined - ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: ` - : ''; - - let serviceError = elasticsearchServiceError( - `Elasticsearch API ${operation} failed: ${statusLabel}${extractElasticsearchMessage(error)}` - ); - serviceError.data.reason = 'elasticsearch_api_error'; - serviceError.data.upstreamStatus = status; - - if (error instanceof Error) { - serviceError.setParent(error); - } - - return serviceError; -}; + createApiServiceError(message, { reason: 'elasticsearch_validation_error' }); + +export let elasticsearchApiError = (error: unknown, operation = 'request') => + buildApiServiceError(error, { + providerLabel: 'Elasticsearch', + operation, + reason: 'elasticsearch_api_error', + detailKeys: ['reason', 'message', 'type', 'error', 'status'], + nestedKeys: ['error', 'root_cause', 'caused_by', 'failed_shards', 'errors', 'reason'], + includeNumbers: false, + extractUpstreamCode: (_input, response, helpers) => { + if (!helpers.isRecord(response?.data)) return undefined; + + let data = response.data; + let errorData = helpers.isRecord(data.error) ? data.error : data; + let code = errorData.type ?? errorData.error; + + return typeof code === 'string' || typeof code === 'number' ? String(code) : undefined; + } + }); diff --git a/integrations/elasticsearch/src/tools.schema.test.ts b/integrations/elasticsearch/src/tools.schema.test.ts new file mode 100644 index 0000000000..1b9fe44c63 --- /dev/null +++ b/integrations/elasticsearch/src/tools.schema.test.ts @@ -0,0 +1,4 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('Elasticsearch tool input schemas', provider.actions); diff --git a/integrations/elasticsearch/src/tools/get-document.ts b/integrations/elasticsearch/src/tools/get-document.ts index b6704b6b3c..158f49419e 100644 --- a/integrations/elasticsearch/src/tools/get-document.ts +++ b/integrations/elasticsearch/src/tools/get-document.ts @@ -7,35 +7,42 @@ import { spec } from '../spec'; export let getDocumentTool = SlateTool.create(spec, { name: 'Get Document', key: 'get_document', - description: `Retrieve one or more documents by ID from an Elasticsearch index. Supports fetching a single document or multiple documents across indices using multi-get.`, + description: `Retrieve one or more known Elasticsearch documents by ID. This tool is for direct document ID lookups only; use search_documents for Query DSL searches, size limits, sorting, filtering, and unknown document discovery.`, + instructions: [ + 'Use documentId with indexName when the exact document ID is known', + 'Use documentIds with indexName or documents for multi-get by known IDs', + 'Do not pass Query DSL, size, sort, or pagination fields here; use search_documents for those search-shaped requests' + ], tags: { destructive: false, readOnly: true } }) .input( - z.object({ - indexName: z - .string() - .optional() - .describe( - 'Name of the index to retrieve the document from. Required for documentId and documentIds requests' - ), - documentId: z.string().optional().describe('ID of a single document to retrieve'), - documentIds: z - .array(z.string()) - .optional() - .describe('Array of document IDs for multi-get from indexName'), - documents: z - .array( - z.object({ - indexName: z.string().describe('Index containing this document'), - documentId: z.string().describe('Document ID to retrieve') - }) - ) - .optional() - .describe('Documents to retrieve across one or more indices') - }) + z + .object({ + indexName: z + .string() + .optional() + .describe( + 'Name of the index to retrieve the document from. Required for documentId and documentIds requests' + ), + documentId: z.string().optional().describe('ID of a single document to retrieve'), + documentIds: z + .array(z.string()) + .optional() + .describe('Array of document IDs for multi-get from indexName'), + documents: z + .array( + z.object({ + indexName: z.string().describe('Index containing this document'), + documentId: z.string().describe('Document ID to retrieve') + }) + ) + .optional() + .describe('Documents to retrieve across one or more indices') + }) + .strict() ) .output( z.object({ diff --git a/integrations/elasticsearch/src/tools/search-documents.ts b/integrations/elasticsearch/src/tools/search-documents.ts index 043d782b4a..c48f0979de 100644 --- a/integrations/elasticsearch/src/tools/search-documents.ts +++ b/integrations/elasticsearch/src/tools/search-documents.ts @@ -96,7 +96,7 @@ export let searchDocumentsTool = SlateTool.create(spec, { let hits = (result.hits?.hits || []).map((hit: any) => ({ indexName: hit._index, documentId: hit._id, - score: hit._score, + score: hit._score ?? null, source: hit._source, highlight: hit.highlight })); diff --git a/integrations/elasticsearch/vitest.config.ts b/integrations/elasticsearch/vitest.config.ts new file mode 100644 index 0000000000..0b05bc07ad --- /dev/null +++ b/integrations/elasticsearch/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/integrations/google-drive/package.json b/integrations/google-drive/package.json index 651de0e166..acc56cf677 100644 --- a/integrations/google-drive/package.json +++ b/integrations/google-drive/package.json @@ -17,5 +17,5 @@ "typescript": "^5", "vitest": "^3.1.2" }, - "version": "0.2.0-rc.7" + "version": "0.2.0-rc.8" } diff --git a/integrations/google-drive/src/lib/client.test.ts b/integrations/google-drive/src/lib/client.test.ts new file mode 100644 index 0000000000..f5708cedea --- /dev/null +++ b/integrations/google-drive/src/lib/client.test.ts @@ -0,0 +1,168 @@ +import { ServiceError } from '@lowerdeck/error'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +let axiosMocks = vi.hoisted(() => ({ + api: { + get: vi.fn() + }, + uploadApi: { + post: vi.fn() + }, + createAxios: vi.fn() +})); + +vi.mock('slates', async importOriginal => { + let actual = await importOriginal(); + + return { + ...actual, + createAxios: axiosMocks.createAxios + }; +}); + +import { + GOOGLE_DRIVE_DEFAULT_PAGE_SIZE, + GOOGLE_DRIVE_MAX_PAGE_SIZE, + GoogleDriveClient, + MAX_DRIVE_DOWNLOAD_BYTES, + normalizeGoogleDrivePageSize, + normalizeGoogleDrivePageToken +} from './client'; + +beforeEach(() => { + axiosMocks.api.get.mockReset(); + axiosMocks.uploadApi.post.mockReset(); + axiosMocks.createAxios.mockReset(); + axiosMocks.createAxios + .mockReturnValueOnce(axiosMocks.api) + .mockReturnValueOnce(axiosMocks.uploadApi); +}); + +describe('GoogleDriveClient pagination validation', () => { + it('normalizes page tokens before calling Drive', async () => { + axiosMocks.api.get.mockResolvedValue({ + data: { + files: [], + nextPageToken: 'next-token' + } + }); + + let client = new GoogleDriveClient('token'); + await client.listFiles({ + pageSize: 25, + pageToken: ' next-token ' + }); + + expect(axiosMocks.api.get).toHaveBeenCalledWith('/files', { + params: expect.objectContaining({ + pageSize: 25, + pageToken: 'next-token' + }) + }); + }); + + it('omits blank page tokens and rejects known non-token placeholders', () => { + expect(normalizeGoogleDrivePageToken(undefined)).toBeUndefined(); + expect(normalizeGoogleDrivePageToken(' ')).toBeUndefined(); + expect(normalizeGoogleDrivePageToken(' token ')).toBe('token'); + + expect(() => normalizeGoogleDrivePageToken('(no output)')).toThrow(ServiceError); + expect(() => normalizeGoogleDrivePageToken('undefined')).toThrow(ServiceError); + expect(() => normalizeGoogleDrivePageToken('null')).toThrow(ServiceError); + }); + + it('rejects page sizes outside the Drive files.list range', () => { + expect(normalizeGoogleDrivePageSize(undefined)).toBe(GOOGLE_DRIVE_DEFAULT_PAGE_SIZE); + expect(normalizeGoogleDrivePageSize(1)).toBe(1); + expect(normalizeGoogleDrivePageSize(GOOGLE_DRIVE_MAX_PAGE_SIZE)).toBe( + GOOGLE_DRIVE_MAX_PAGE_SIZE + ); + + expect(() => normalizeGoogleDrivePageSize(0)).toThrow(ServiceError); + expect(() => normalizeGoogleDrivePageSize(GOOGLE_DRIVE_MAX_PAGE_SIZE + 1)).toThrow( + ServiceError + ); + expect(() => normalizeGoogleDrivePageSize(1.5)).toThrow(ServiceError); + }); + + it('turns Drive 400s for pageToken requests into actionable ServiceErrors', async () => { + axiosMocks.api.get.mockRejectedValue({ + response: { + status: 400, + data: { + error: { + message: 'Invalid page token' + } + } + } + }); + + let client = new GoogleDriveClient('token'); + + await expect( + client.listFiles({ + pageToken: 'stale-token' + }) + ).rejects.toMatchObject({ + data: { + reason: 'invalid_page_token', + upstreamStatus: 400 + } + }); + }); +}); + +describe('GoogleDriveClient download guards', () => { + it('rejects Google Workspace downloads with a ServiceError', async () => { + axiosMocks.api.get.mockResolvedValue({ + data: { + mimeType: 'application/vnd.google-apps.document', + name: 'Native Doc' + } + }); + + let client = new GoogleDriveClient('token'); + + await expect(client.downloadFile('doc-id')).rejects.toMatchObject({ + data: { + reason: 'google_workspace_download_requires_export' + } + }); + expect(axiosMocks.api.get).toHaveBeenCalledTimes(1); + }); + + it('rejects files larger than the MCP-safe download limit with a ServiceError', async () => { + axiosMocks.api.get.mockResolvedValue({ + data: { + mimeType: 'text/plain', + size: String(MAX_DRIVE_DOWNLOAD_BYTES + 1) + } + }); + + let client = new GoogleDriveClient('token'); + + await expect(client.downloadFile('large-file-id')).rejects.toMatchObject({ + data: { + reason: 'drive_download_too_large' + } + }); + expect(axiosMocks.api.get).toHaveBeenCalledTimes(1); + }); + + it('returns ServiceError for missing files discovered during metadata lookup', async () => { + axiosMocks.api.get.mockRejectedValue({ + response: { + status: 404 + } + }); + + let client = new GoogleDriveClient('token'); + + await expect(client.downloadFile('missing-file-id')).rejects.toMatchObject({ + data: { + reason: 'drive_file_not_found', + upstreamStatus: 404 + } + }); + }); +}); diff --git a/integrations/google-drive/src/lib/client.ts b/integrations/google-drive/src/lib/client.ts index b1ab030f8f..12fb777e4f 100644 --- a/integrations/google-drive/src/lib/client.ts +++ b/integrations/google-drive/src/lib/client.ts @@ -1,4 +1,4 @@ -import { createAxios } from 'slates'; +import { createApiServiceError, createAxios } from 'slates'; import type { ChangeListResponse, CommentListResponse, @@ -20,8 +20,16 @@ let FILE_FIELDS = /** Max bytes returned in one **Download File** response (base64 in JSON); avoids MCP / JSON payload limits. */ export let MAX_DRIVE_DOWNLOAD_BYTES = 6 * 1024 * 1024; +export let GOOGLE_DRIVE_DEFAULT_PAGE_SIZE = 100; +export let GOOGLE_DRIVE_MAX_PAGE_SIZE = 100; const GOOGLE_WORKSPACE_MIME_PREFIX = 'application/vnd.google-apps.'; +const INVALID_PAGE_TOKEN_PLACEHOLDERS = new Set([ + '(no output)', + 'no output', + 'undefined', + 'null' +]); const TEXT_MIME_BY_EXTENSION: Record = { csv: 'text/csv', @@ -180,13 +188,46 @@ function httpStatusFromAxiosError(e: unknown): number | undefined { return typeof s === 'number' ? s : undefined; } +export function normalizeGoogleDrivePageToken(pageToken?: string): string | undefined { + if (pageToken === undefined) return undefined; + + let trimmed = pageToken.trim(); + if (!trimmed) return undefined; + + if (INVALID_PAGE_TOKEN_PLACEHOLDERS.has(trimmed.toLowerCase())) { + throw createApiServiceError( + `Invalid pageToken "${trimmed}". Omit pageToken unless it is exactly the nextPageToken returned by a previous Google Drive response.`, + { reason: 'invalid_page_token' } + ); + } + + return trimmed; +} + +export function normalizeGoogleDrivePageSize(pageSize?: number): number { + if (pageSize === undefined) return GOOGLE_DRIVE_DEFAULT_PAGE_SIZE; + + if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > GOOGLE_DRIVE_MAX_PAGE_SIZE) { + throw createApiServiceError( + `pageSize must be an integer between 1 and ${GOOGLE_DRIVE_MAX_PAGE_SIZE}.`, + { reason: 'invalid_page_size' } + ); + } + + return pageSize; +} + /** Replace generic axios 404 with something actionable (wrong id, wrong account, no access). */ -function driveFileNotFoundError(fileId: string): Error { - return new Error( +function driveFileNotFoundError(fileId: string) { + return createApiServiceError( `Drive returned 404 for file id "${fileId}". ` + `Google does not expose that id to the signed-in Google account (wrong id, deleted/trashed file, or different account than in the browser). ` + - `Copy the id from the open tab URL (/file/d/…, /document/d/…, etc.), confirm Slates Hub / this app uses the same Google user, ` + - `and check for typos (I vs l, O vs 0).` + `Copy the id from the open tab URL (/file/d/..., /document/d/..., etc.), confirm Slates Hub / this app uses the same Google user, ` + + `and check for typos (I vs l, O vs 0).`, + { + reason: 'drive_file_not_found', + upstreamStatus: 404 + } ); } @@ -248,15 +289,16 @@ export class GoogleDriveClient { driveId?: string; spaces?: string; }): Promise { + let pageToken = normalizeGoogleDrivePageToken(params.pageToken); let requestParams: Record = { fields: `nextPageToken,incompleteSearch,files(${FILE_FIELDS})`, - pageSize: params.pageSize || 100, + pageSize: normalizeGoogleDrivePageSize(params.pageSize), supportsAllDrives: true, includeItemsFromAllDrives: true }; if (params.query) requestParams.q = params.query; - if (params.pageToken) requestParams.pageToken = params.pageToken; + if (pageToken) requestParams.pageToken = pageToken; if (params.orderBy) requestParams.orderBy = params.orderBy; if (params.driveId) { requestParams.driveId = params.driveId; @@ -264,7 +306,23 @@ export class GoogleDriveClient { } if (params.spaces) requestParams.spaces = params.spaces; - let response = await this.api.get('/files', { params: requestParams }); + let response: any; + try { + response = await this.api.get('/files', { params: requestParams }); + } catch (e) { + if (pageToken && httpStatusFromAxiosError(e) === 400) { + throw createApiServiceError( + 'Google Drive rejected pageToken. Discard the token and restart pagination by omitting pageToken; only reuse the exact nextPageToken from a previous response with the same query, orderBy, driveId, and spaces.', + { + reason: 'invalid_page_token', + upstreamStatus: 400, + parent: e + } + ); + } + throw e; + } + return { files: (response.data.files || []).map(mapFile), nextPageToken: response.data.nextPageToken, @@ -430,17 +488,19 @@ export class GoogleDriveClient { ): Promise<{ contentBase64: string; mimeType?: string; byteLength: number }> { let meta = await this.getFileLightMeta(fileId); if (meta.mimeType?.startsWith('application/vnd.google-apps.')) { - throw new Error( + throw createApiServiceError( `This file is Google Workspace format (${meta.mimeType}${meta.name ? `, "${meta.name}"` : ''}). ` + `Drive does not allow \`alt=media\` download for Docs/Sheets/Slides/etc. Use the **Export File** tool instead ` + - `(e.g. \`text/plain\`, \`application/pdf\`, or DOCX/XLSX depending on the source type).` + `(e.g. \`text/plain\`, \`application/pdf\`, or DOCX/XLSX depending on the source type).`, + { reason: 'google_workspace_download_requires_export' } ); } let declaredBytes = meta.size !== undefined && meta.size !== '' ? Number(meta.size) : Number.NaN; if (Number.isFinite(declaredBytes) && declaredBytes > MAX_DRIVE_DOWNLOAD_BYTES) { - throw new Error( - `File size (~${declaredBytes} bytes) exceeds this tool’s limit of ${MAX_DRIVE_DOWNLOAD_BYTES} bytes for MCP-safe JSON payloads. Download via another path or split the file.` + throw createApiServiceError( + `File size (~${declaredBytes} bytes) exceeds this tool's limit of ${MAX_DRIVE_DOWNLOAD_BYTES} bytes for MCP-safe JSON payloads. Download via another path or split the file.`, + { reason: 'drive_download_too_large' } ); } @@ -458,8 +518,9 @@ export class GoogleDriveClient { } let buf = Buffer.from(response.data as ArrayBuffer); if (buf.length > MAX_DRIVE_DOWNLOAD_BYTES) { - throw new Error( - `Downloaded ${buf.length} bytes, which exceeds the tool limit of ${MAX_DRIVE_DOWNLOAD_BYTES} bytes for MCP-safe JSON.` + throw createApiServiceError( + `Downloaded ${buf.length} bytes, which exceeds the tool limit of ${MAX_DRIVE_DOWNLOAD_BYTES} bytes for MCP-safe JSON.`, + { reason: 'drive_download_too_large' } ); } let ct = response.headers['content-type']; diff --git a/integrations/google-drive/src/provider.contract.test.ts b/integrations/google-drive/src/provider.contract.test.ts index 4da1d2b000..756142ab14 100644 --- a/integrations/google-drive/src/provider.contract.test.ts +++ b/integrations/google-drive/src/provider.contract.test.ts @@ -1,4 +1,8 @@ -import { createLocalSlateTestClient, expectSlateContract } from '@slates/test'; +import { + createLocalSlateTestClient, + describeMcpCompatibleToolSchemas, + expectSlateContract +} from '@slates/test'; import { describe, expect, it } from 'vitest'; import { provider } from './index'; import { googleDriveActionScopes } from './scopes'; @@ -115,3 +119,5 @@ describe('google-drive provider contract', () => { expect(scopeTitles.has('User Email')).toBe(true); }); }); + +describeMcpCompatibleToolSchemas('Google Drive tool input schemas', provider.actions); diff --git a/integrations/google-drive/src/tools/search-files.ts b/integrations/google-drive/src/tools/search-files.ts index cf9fbe65f4..ea27eb39da 100644 --- a/integrations/google-drive/src/tools/search-files.ts +++ b/integrations/google-drive/src/tools/search-files.ts @@ -1,6 +1,6 @@ import { SlateTool } from 'slates'; import { z } from 'zod'; -import { GoogleDriveClient } from '../lib/client'; +import { GOOGLE_DRIVE_MAX_PAGE_SIZE, GoogleDriveClient } from '../lib/client'; import { googleDriveActionScopes } from '../scopes'; import { spec } from '../spec'; @@ -40,7 +40,7 @@ export let searchFilesTool = SlateTool.create(spec, { 'Combine conditions with "and"/"or": name contains \'budget\' and mimeType = \'application/vnd.google-apps.spreadsheet\'.', "To find folders, use: mimeType = 'application/vnd.google-apps.folder'.", "To list files in a specific folder, use: 'FOLDER_ID' in parents.", - 'Pagination: when using `pageToken`, keep the same `query`, `orderBy`, and `driveId` as the request that returned that token. Changing filters while reusing a token returns HTTP 400.' + 'Pagination: omit `pageToken` unless it is exactly the `nextPageToken` from a previous response. When using `pageToken`, keep the same `query`, `orderBy`, and `driveId` as the request that returned that token. Changing filters while reusing a token returns HTTP 400.' ], tags: { readOnly: true @@ -57,9 +57,19 @@ export let searchFilesTool = SlateTool.create(spec, { ), pageSize: z .number() + .int() + .min(1) + .max(GOOGLE_DRIVE_MAX_PAGE_SIZE) .optional() - .describe('Maximum number of files to return (1-1000, default 100)'), - pageToken: z.string().optional().describe('Token for fetching the next page of results'), + .describe( + `Maximum number of files to return (1-${GOOGLE_DRIVE_MAX_PAGE_SIZE}, default 100)` + ), + pageToken: z + .string() + .optional() + .describe( + 'Token for fetching the next page of results. Must be the exact nextPageToken returned by a previous response; omit this field for the first page or when no token is available.' + ), orderBy: z .string() .optional() From 49b2da5b1bb72bd860a0c414052031e3ad718c72 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 24 Jun 2026 16:52:44 +0200 Subject: [PATCH 3/4] Fix Slack integration failures --- bun.lock | 4 +- integrations/google-sheets/package.json | 2 +- .../google-sheets/src/lib/a1-range.test.ts | 37 ++++++++++++++++ .../google-sheets/src/lib/a1-range.ts | 44 +++++++++++++++++++ integrations/google-sheets/src/lib/client.ts | 21 +++++---- .../google-sheets/src/tools/clear-cells.ts | 10 ++++- .../google-sheets/src/tools/read-cells.ts | 12 ++++- .../google-sheets/src/tools/write-cells.ts | 15 +++++-- integrations/slack/package.json | 2 +- integrations/slack/src/tools/manage-files.ts | 4 +- .../slack/src/tools/schedule-message.ts | 12 ++++- integrations/slack/src/tools/send-message.ts | 6 ++- 12 files changed, 146 insertions(+), 23 deletions(-) create mode 100644 integrations/google-sheets/src/lib/a1-range.test.ts create mode 100644 integrations/google-sheets/src/lib/a1-range.ts diff --git a/bun.lock b/bun.lock index 4d2527b289..218e9140e9 100644 --- a/bun.lock +++ b/bun.lock @@ -5689,7 +5689,7 @@ }, "integrations/google-sheets": { "name": "@slates-integrations/google-sheets", - "version": "0.2.0-rc.9", + "version": "0.2.0-rc.10", "dependencies": { "@types/node": "^20", "slates": "1.0.0-rc.15", @@ -11482,7 +11482,7 @@ }, "integrations/slack": { "name": "@slates-integrations/slack", - "version": "0.2.0-rc.26", + "version": "0.2.0-rc.27", "dependencies": { "@lowerdeck/error": "^1.1.0", "@slates/slack-tools": "1.0.0-rc.8", diff --git a/integrations/google-sheets/package.json b/integrations/google-sheets/package.json index d03dc550de..e5114cc716 100644 --- a/integrations/google-sheets/package.json +++ b/integrations/google-sheets/package.json @@ -17,5 +17,5 @@ "typescript": "^5", "vitest": "^3.1.2" }, - "version": "0.2.0-rc.9" + "version": "0.2.0-rc.10" } diff --git a/integrations/google-sheets/src/lib/a1-range.test.ts b/integrations/google-sheets/src/lib/a1-range.test.ts new file mode 100644 index 0000000000..99e1876206 --- /dev/null +++ b/integrations/google-sheets/src/lib/a1-range.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeA1Range } from './a1-range'; + +describe('normalizeA1Range', () => { + it('leaves simple sheet names unchanged', () => { + expect(normalizeA1Range('Sheet1!A1:B2')).toBe('Sheet1!A1:B2'); + expect(normalizeA1Range('Data!A:A')).toBe('Data!A:A'); + }); + + it('leaves no-sheet ranges and named ranges unchanged', () => { + expect(normalizeA1Range('A1:B2')).toBe('A1:B2'); + expect(normalizeA1Range('NamedRange')).toBe('NamedRange'); + }); + + it('quotes sheet names with spaces or punctuation', () => { + expect(normalizeA1Range('Draft summary!A1:Z60')).toBe("'Draft summary'!A1:Z60"); + expect(normalizeA1Range('Authority (ETV)!E1:H1')).toBe("'Authority (ETV)'!E1:H1"); + }); + + it('leaves already quoted sheet names quoted while trimming the separator', () => { + expect(normalizeA1Range(" 'Target Pages' ! D1 ")).toBe("'Target Pages'!D1"); + expect(normalizeA1Range("'Jon''s_Data'!A1:D5")).toBe("'Jon''s_Data'!A1:D5"); + }); + + it('escapes apostrophes when quoting sheet names', () => { + expect(normalizeA1Range("Jon's Data!A1:D5")).toBe("'Jon''s Data'!A1:D5"); + }); + + it('uses the final separator for unquoted sheet names that contain punctuation', () => { + expect(normalizeA1Range('Needs!Quotes!A1')).toBe("'Needs!Quotes'!A1"); + }); + + it('quotes ambiguous cell-like sheet names', () => { + expect(normalizeA1Range('A1!B2')).toBe("'A1'!B2"); + expect(normalizeA1Range('R1C1!A1')).toBe("'R1C1'!A1"); + }); +}); diff --git a/integrations/google-sheets/src/lib/a1-range.ts b/integrations/google-sheets/src/lib/a1-range.ts new file mode 100644 index 0000000000..2cce7dc5e3 --- /dev/null +++ b/integrations/google-sheets/src/lib/a1-range.ts @@ -0,0 +1,44 @@ +const findA1SheetSeparator = (range: string) => { + if (!range.startsWith("'")) { + return range.lastIndexOf('!'); + } + + for (let i = 1; i < range.length; i += 1) { + if (range[i] !== "'") continue; + + if (range[i + 1] === "'") { + i += 1; + continue; + } + + let rest = range.slice(i + 1); + let bangOffset = rest.search(/\s*!/); + return bangOffset === -1 ? -1 : i + 1 + bangOffset + rest.slice(bangOffset).indexOf('!'); + } + + return -1; +}; + +const quoteA1SheetName = (sheetName: string) => `'${sheetName.replaceAll("'", "''")}'`; + +const isAmbiguousA1SheetName = (sheetName: string) => + /^[A-Za-z]{1,3}\d+$/.test(sheetName) || /^R\d+C\d+$/i.test(sheetName); + +const shouldQuoteA1SheetName = (sheetName: string) => + !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sheetName) || isAmbiguousA1SheetName(sheetName); + +export const normalizeA1Range = (range: string) => { + let trimmed = range.trim(); + let separatorIndex = findA1SheetSeparator(trimmed); + + if (separatorIndex === -1) return trimmed; + + let sheetName = trimmed.slice(0, separatorIndex).trim(); + let location = trimmed.slice(separatorIndex + 1).trim(); + + if (!sheetName || !location) return trimmed; + if (sheetName.startsWith("'") && sheetName.endsWith("'")) return `${sheetName}!${location}`; + if (!shouldQuoteA1SheetName(sheetName)) return `${sheetName}!${location}`; + + return `${quoteA1SheetName(sheetName)}!${location}`; +}; diff --git a/integrations/google-sheets/src/lib/client.ts b/integrations/google-sheets/src/lib/client.ts index 43a1e411da..7abb31cd0b 100644 --- a/integrations/google-sheets/src/lib/client.ts +++ b/integrations/google-sheets/src/lib/client.ts @@ -1,4 +1,5 @@ import { createAxios } from 'slates'; +import { normalizeA1Range } from './a1-range'; export class SheetsClient { private axios: ReturnType; @@ -77,8 +78,9 @@ export class SheetsClient { dateTimeRenderOption?: 'SERIAL_NUMBER' | 'FORMATTED_STRING'; } ) { + let normalizedRange = normalizeA1Range(range); let response = await this.axios.get( - `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}`, + `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(normalizedRange)}`, { params: options } ); return response.data; @@ -95,7 +97,7 @@ export class SheetsClient { ) { let params = new URLSearchParams(); for (let range of ranges) { - params.append('ranges', range); + params.append('ranges', normalizeA1Range(range)); } if (options?.majorDimension !== undefined) { params.set('majorDimension', options.majorDimension); @@ -126,10 +128,11 @@ export class SheetsClient { } ) { let valueInputOption = options?.valueInputOption ?? 'USER_ENTERED'; + let normalizedRange = normalizeA1Range(range); let response = await this.axios.put( - `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}`, + `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(normalizedRange)}`, { - range, + range: normalizedRange, majorDimension: options?.majorDimension ?? 'ROWS', values }, @@ -155,7 +158,7 @@ export class SheetsClient { let response = await this.axios.post(`/spreadsheets/${spreadsheetId}/values:batchUpdate`, { valueInputOption, data: data.map(d => ({ - range: d.range, + range: normalizeA1Range(d.range), majorDimension: 'ROWS', values: d.values })), @@ -175,10 +178,11 @@ export class SheetsClient { } ) { let valueInputOption = options?.valueInputOption ?? 'USER_ENTERED'; + let normalizedRange = normalizeA1Range(range); let response = await this.axios.post( - `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}:append`, + `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(normalizedRange)}:append`, { - range, + range: normalizedRange, majorDimension: 'ROWS', values }, @@ -194,8 +198,9 @@ export class SheetsClient { } async clearValues(spreadsheetId: string, range: string) { + let normalizedRange = normalizeA1Range(range); let response = await this.axios.post( - `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}:clear` + `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(normalizedRange)}:clear` ); return response.data; } diff --git a/integrations/google-sheets/src/tools/clear-cells.ts b/integrations/google-sheets/src/tools/clear-cells.ts index f544a2e43d..04affc0c1d 100644 --- a/integrations/google-sheets/src/tools/clear-cells.ts +++ b/integrations/google-sheets/src/tools/clear-cells.ts @@ -8,6 +8,10 @@ export let clearCells = SlateTool.create(spec, { name: 'Clear Cells', key: 'clear_cells', description: `Clears all values from a specified range in a spreadsheet while preserving formatting. Use this to remove cell contents without deleting the cells themselves.`, + instructions: [ + 'Use A1 notation for ranges, e.g., "Sheet1!A1:D10".', + 'Quote sheet names that contain spaces or special characters, e.g., "\'Draft summary\'!A1:Z80".' + ], tags: { destructive: true, readOnly: false @@ -17,7 +21,11 @@ export let clearCells = SlateTool.create(spec, { .input( z.object({ spreadsheetId: z.string().describe('Unique ID of the spreadsheet'), - range: z.string().describe('Range to clear in A1 notation (e.g., "Sheet1!A1:D10")') + range: z + .string() + .describe( + 'Range to clear in A1 notation (e.g., "Sheet1!A1:D10" or "\'Draft summary\'!A1:Z80")' + ) }) ) .output( diff --git a/integrations/google-sheets/src/tools/read-cells.ts b/integrations/google-sheets/src/tools/read-cells.ts index 039abd763b..bb2b50fd01 100644 --- a/integrations/google-sheets/src/tools/read-cells.ts +++ b/integrations/google-sheets/src/tools/read-cells.ts @@ -10,6 +10,7 @@ export let readCells = SlateTool.create(spec, { description: `Reads values from one or more ranges in a spreadsheet. Supports A1 notation (e.g., "Sheet1!A1:B10") and named ranges. Can read a single range or multiple ranges at once. Returns values as formatted strings, raw values, or formulas.`, instructions: [ 'Use A1 notation to specify ranges, e.g., "Sheet1!A1:D10", "A1:B5", "Sheet1!A:A" for an entire column.', + 'Quote sheet names that contain spaces or special characters, e.g., "\'Draft summary\'!A1:Z80".', 'For multiple ranges, pass an array to the "ranges" field instead of using "range".' ], tags: { @@ -24,8 +25,15 @@ export let readCells = SlateTool.create(spec, { range: z .string() .optional() - .describe('Single range in A1 notation (e.g., "Sheet1!A1:B10")'), - ranges: z.array(z.string()).optional().describe('Multiple ranges to read at once'), + .describe( + 'Single range in A1 notation (e.g., "Sheet1!A1:B10" or "\'Draft summary\'!A1:Z80")' + ), + ranges: z + .array(z.string()) + .optional() + .describe( + 'Multiple ranges to read at once. Quote sheet names with spaces or special characters.' + ), valueRenderOption: z .enum(['FORMATTED_VALUE', 'UNFORMATTED_VALUE', 'FORMULA']) .optional() diff --git a/integrations/google-sheets/src/tools/write-cells.ts b/integrations/google-sheets/src/tools/write-cells.ts index f33caef736..8487ca18f7 100644 --- a/integrations/google-sheets/src/tools/write-cells.ts +++ b/integrations/google-sheets/src/tools/write-cells.ts @@ -1,4 +1,4 @@ -import { SlateTool } from 'slates'; +import { createApiServiceError, SlateTool } from 'slates'; import { z } from 'zod'; import { SheetsClient } from '../lib/client'; import { googleSheetsActionScopes } from '../scopes'; @@ -10,6 +10,7 @@ export let writeCells = SlateTool.create(spec, { description: `Writes values to one or more ranges in a spreadsheet. Supports single-range writes, multi-range batch writes, and appending data to the end of a table. Values can be written as raw data or interpreted as user input (parsing dates, formulas, etc.).`, instructions: [ 'Use A1 notation for ranges, e.g., "Sheet1!A1:B2".', + 'Quote sheet names that contain spaces or special characters, e.g., "\'Draft summary\'!A1:Z80".', 'Values are a 2D array where each inner array is a row.', 'Set valueInputOption to "USER_ENTERED" to have values parsed as if typed in the UI (formulas, dates, etc.) or "RAW" for literal values.', 'Use "append" mode to add rows to the end of a table detected at the given range.' @@ -26,7 +27,9 @@ export let writeCells = SlateTool.create(spec, { range: z .string() .optional() - .describe('Target range in A1 notation for a single write or append'), + .describe( + 'Target range in A1 notation for a single write or append (e.g., "Sheet1!A1:B2" or "\'Draft summary\'!A1:Z80")' + ), values: z .array(z.array(z.any())) .optional() @@ -34,7 +37,11 @@ export let writeCells = SlateTool.create(spec, { rangeValues: z .array( z.object({ - range: z.string().describe('Target range in A1 notation'), + range: z + .string() + .describe( + 'Target range in A1 notation. Quote sheet names with spaces or special characters.' + ), values: z.array(z.array(z.any())).describe('2D array of values for this range') }) ) @@ -102,7 +109,7 @@ export let writeCells = SlateTool.create(spec, { let values = ctx.input.values; if (!range || !values) { - throw new Error( + throw createApiServiceError( 'Either "range" and "values" must be provided, or "rangeValues" for batch writes' ); } diff --git a/integrations/slack/package.json b/integrations/slack/package.json index 15de041810..a08080533c 100644 --- a/integrations/slack/package.json +++ b/integrations/slack/package.json @@ -19,5 +19,5 @@ "typescript": "^5", "vitest": "^3.1.2" }, - "version": "0.2.0-rc.26" + "version": "0.2.0-rc.27" } diff --git a/integrations/slack/src/tools/manage-files.ts b/integrations/slack/src/tools/manage-files.ts index d5591f1807..8887cf96ef 100644 --- a/integrations/slack/src/tools/manage-files.ts +++ b/integrations/slack/src/tools/manage-files.ts @@ -54,7 +54,9 @@ export let manageFiles = SlateTool.create(spec, { channelIds: z .string() .optional() - .describe('Comma-separated channel IDs to share the file to'), + .describe( + 'Comma-separated Slack conversation IDs to share the file to, such as C..., G..., or D...; do not pass channel names like #general' + ), initialComment: z.string().optional().describe('Comment to add when sharing the file'), threadTs: z .string() diff --git a/integrations/slack/src/tools/schedule-message.ts b/integrations/slack/src/tools/schedule-message.ts index 073df5c096..3858b1f481 100644 --- a/integrations/slack/src/tools/schedule-message.ts +++ b/integrations/slack/src/tools/schedule-message.ts @@ -19,12 +19,20 @@ export let scheduleMessage = SlateTool.create(spec, { tags: { destructive: false, readOnly: false - } + }, + instructions: [ + 'Use the Slack conversation ID, such as C..., G..., or D...; do not pass a channel name like #general.', + 'Provide either text or at least one Block Kit block.' + ] }) .scopes(slackActionScopes.chatWrite) .input( z.object({ - channelId: z.string().describe('Channel ID to send the scheduled message to'), + channelId: z + .string() + .describe( + 'Slack conversation ID to send the scheduled message to, such as C..., G..., or D...; do not pass a channel name like #general' + ), postAt: z.number().describe('Unix timestamp (in seconds) for when to send the message'), text: z.string().optional().describe('Message text (supports Slack mrkdwn formatting)'), blocks: z.array(z.any()).optional().describe('Array of Block Kit block objects'), diff --git a/integrations/slack/src/tools/send-message.ts b/integrations/slack/src/tools/send-message.ts index 6f2f22fc0a..8327544d10 100644 --- a/integrations/slack/src/tools/send-message.ts +++ b/integrations/slack/src/tools/send-message.ts @@ -26,7 +26,11 @@ export let sendMessage = SlateTool.create(spec, { .scopes(slackActionScopes.chatWrite) .input( z.object({ - channelId: z.string().describe('Channel, DM, or group DM ID to send the message to'), + channelId: z + .string() + .describe( + 'Slack conversation ID to send the message to, such as C..., G..., or D...; do not pass a channel name like #general' + ), text: z.string().optional().describe('Message text (supports Slack mrkdwn formatting)'), blocks: z .array(z.any()) From fb0cfecc86a557a7c91d639f00a64088661cddc2 Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 25 Jun 2026 12:19:29 +0200 Subject: [PATCH 4/4] Fix build --- bun.lock | 11 +++-------- integrations/google-drive/package.json | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index 218e9140e9..910aa20756 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "@metorial/integrations-root", @@ -5366,7 +5365,7 @@ }, "integrations/gmail": { "name": "@slates-integrations/gmail", - "version": "0.2.0-rc.13", + "version": "0.2.0-rc.14", "dependencies": { "@lowerdeck/error": "^1.1.0", "@slates/google-people-recipes": "1.0.0-rc.4", @@ -5604,10 +5603,10 @@ }, "integrations/google-drive": { "name": "@slates-integrations/google-drive", - "version": "0.2.0-rc.7", + "version": "0.2.0-rc.8", "dependencies": { "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2", }, "devDependencies": { @@ -18844,8 +18843,6 @@ "@slates-integrations/google-docs/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/google-drive/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], - "@slates-integrations/google-forms/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], "@slates-integrations/google-maps/slates": ["slates@1.0.0-rc.14", "", { "dependencies": { "@slates/provider": "1.0.0-rc.15" } }, "sha512-TDfy7f9eJPCS5IpTwBq0jDOt5azw283YTt7vLTUPKoF4Cx6D6I7EZkV73NoUsL3qWnCQ0KY+vpH+UFGdnXzDtw=="], @@ -20904,8 +20901,6 @@ "@slates-integrations/google-docs/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/google-drive/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], - "@slates-integrations/google-forms/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], "@slates-integrations/google-maps/slates/@slates/provider": ["@slates/provider@1.0.0-rc.15", "", { "dependencies": { "@lowerdeck/error": "^1.1.0", "@toon-format/toon": "^2.1.0", "axios": "^1.13.2", "zod": "^4.2.1" } }, "sha512-l28Ut+NsRYZ5X7Ml+ERaTFvOJe0G72SGJiHV4DNgFdeETjjDDBOKcj/OwIBQN0l0s6AxxqiXQIyZeQsRaDnDlA=="], diff --git a/integrations/google-drive/package.json b/integrations/google-drive/package.json index acc56cf677..341db25ffb 100644 --- a/integrations/google-drive/package.json +++ b/integrations/google-drive/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@types/node": "^20", - "slates": "1.0.0-rc.14", + "slates": "1.0.0-rc.15", "zod": "^4.2" }, "devDependencies": {