Skip to content

Commit c750f83

Browse files
domdomeggDavidcursoragentclaude
authored
Add upload_attachment tool for direct file uploads (#86)
* Add upload_attachment tool for Airtable API Co-authored-by: Cursor <cursoragent@cursor.com> * Deduplicate AirtableRecordSchema and clarify upload_attachment file description - Extract shared AirtableRecordSchema in types.ts, replacing 5 inline copies across airtableService.ts - Reuse AirtableRecordSchema for upload-attachment.ts outputSchema - Clarify that the file parameter expects raw base64 (no data URL prefix) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: David <david@example.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 08c97c6 commit c750f83

File tree

6 files changed

+95
-8
lines changed

6 files changed

+95
-8
lines changed

src/airtableService.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
FieldSchema,
1616
CommentSchema,
1717
ListCommentsResponseSchema,
18+
AirtableRecordSchema,
1819
type FieldSet,
1920
} from './types.js';
2021
import {enhanceAirtableError} from './enhanceAirtableError.js';
@@ -84,7 +85,7 @@ export class AirtableService implements IAirtableService {
8485
const response = await this.fetchFromAPI(
8586
`/v0/${baseId}/${tableId}?${queryParams.toString()}`,
8687
z.object({
87-
records: z.array(z.object({id: z.string(), fields: z.record(z.string(), z.any())})),
88+
records: z.array(AirtableRecordSchema),
8889
offset: z.string().optional(),
8990
}),
9091
);
@@ -99,14 +100,14 @@ export class AirtableService implements IAirtableService {
99100
async getRecord(baseId: string, tableId: string, recordId: string): Promise<AirtableRecord> {
100101
return this.fetchFromAPI(
101102
`/v0/${baseId}/${tableId}/${recordId}`,
102-
z.object({id: z.string(), fields: z.record(z.string(), z.any())}),
103+
AirtableRecordSchema,
103104
);
104105
}
105106

106107
async createRecord(baseId: string, tableId: string, fields: FieldSet): Promise<AirtableRecord> {
107108
return this.fetchFromAPI(
108109
`/v0/${baseId}/${tableId}`,
109-
z.object({id: z.string(), fields: z.record(z.string(), z.any())}),
110+
AirtableRecordSchema,
110111
{
111112
method: 'POST',
112113
body: JSON.stringify({fields}),
@@ -121,7 +122,7 @@ export class AirtableService implements IAirtableService {
121122
): Promise<AirtableRecord[]> {
122123
const response = await this.fetchFromAPI(
123124
`/v0/${baseId}/${tableId}`,
124-
z.object({records: z.array(z.object({id: z.string(), fields: z.record(z.string(), z.any())}))}),
125+
z.object({records: z.array(AirtableRecordSchema)}),
125126
{
126127
method: 'PATCH',
127128
body: JSON.stringify({records}),
@@ -263,6 +264,27 @@ export class AirtableService implements IAirtableService {
263264
return this.fetchFromAPI(endpoint, ListCommentsResponseSchema);
264265
}
265266

267+
async uploadAttachment(
268+
baseId: string,
269+
recordId: string,
270+
attachmentFieldIdOrName: string,
271+
file: string,
272+
filename: string,
273+
contentType: string,
274+
): Promise<AirtableRecord> {
275+
const contentBaseUrl = 'https://content.airtable.com';
276+
const endpoint = `/v0/${baseId}/${recordId}/${encodeURIComponent(attachmentFieldIdOrName)}/uploadAttachment`;
277+
return this.fetchFromAPI(
278+
endpoint,
279+
AirtableRecordSchema,
280+
{
281+
method: 'POST',
282+
body: JSON.stringify({contentType, file, filename}),
283+
},
284+
contentBaseUrl,
285+
);
286+
}
287+
266288
private async validateAndGetSearchFields(
267289
baseId: string,
268290
tableId: string,
@@ -305,8 +327,14 @@ export class AirtableService implements IAirtableService {
305327
return searchableFields;
306328
}
307329

308-
private async fetchFromAPI<T>(endpoint: string, schema: z.ZodType<T>, options: RequestInit = {}): Promise<T> {
309-
const response = await this.fetch(`${this.baseUrl}${endpoint}`, {
330+
private async fetchFromAPI<T>(
331+
endpoint: string,
332+
schema: z.ZodType<T>,
333+
options: RequestInit = {},
334+
baseUrl?: string,
335+
): Promise<T> {
336+
const url = (baseUrl ?? this.baseUrl) + endpoint;
337+
const response = await this.fetch(url, {
310338
...options,
311339
headers: {
312340
Authorization: `Bearer ${this.apiKey}`,

src/e2e.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ describe.each([
231231
'update_field',
232232
'create_comment',
233233
'list_comments',
234+
'upload_attachment',
234235
]);
235236
expect(result.tools[0]).toMatchObject({
236237
name: 'list_records',

src/mcpServer.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ describe('AirtableMCPServer', () => {
136136
params: {},
137137
});
138138

139-
expect((response.result.tools as Tool[]).length).toBe(15);
139+
expect((response.result.tools as Tool[]).length).toBe(16);
140140
expect((response.result.tools as Tool[])[0]).toMatchObject({
141141
name: expect.any(String),
142142
description: expect.any(String),

src/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {registerCreateField} from './create-field.js';
1515
import {registerUpdateField} from './update-field.js';
1616
import {registerCreateComment} from './create-comment.js';
1717
import {registerListComments} from './list-comments.js';
18+
import {registerUploadAttachment} from './upload-attachment.js';
1819

1920
export type {ToolContext} from './types.js';
2021

@@ -34,4 +35,5 @@ export function registerAll(server: McpServer, ctx: ToolContext): void {
3435
registerUpdateField(server, ctx);
3536
registerCreateComment(server, ctx);
3637
registerListComments(server, ctx);
38+
registerUploadAttachment(server, ctx);
3739
}

src/tools/upload-attachment.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
2+
import {z} from 'zod';
3+
import type {ToolContext} from './types.js';
4+
import {baseId} from './schemas.js';
5+
import {jsonResult} from '../utils/response.js';
6+
import {AirtableRecordSchema} from '../types.js';
7+
8+
const outputSchema = AirtableRecordSchema;
9+
10+
export function registerUploadAttachment(server: McpServer, ctx: ToolContext): void {
11+
server.registerTool(
12+
'upload_attachment',
13+
{
14+
title: 'Upload Attachment',
15+
description:
16+
'Upload a file directly to an attachment field on an existing record using Airtable\'s upload API. Supports files up to 5 MB. For larger files, use create_record or update_records with a public URL. The record must already exist.',
17+
inputSchema: {
18+
...baseId,
19+
recordId: z.string().describe('The ID of the existing record (e.g. recXXXXXXXXXXXXXX)'),
20+
attachmentFieldIdOrName: z
21+
.string()
22+
.describe('The ID or name of the attachment field (e.g. fldXXXXXXXXXXXXXX or "Images")'),
23+
file: z.string().describe('Raw base64-encoded file content (no data URL prefix)'),
24+
filename: z.string().describe('Filename for the attachment (e.g. "image.jpg")'),
25+
contentType: z
26+
.string()
27+
.describe('MIME type of the file (e.g. "image/jpeg", "image/png", "application/pdf")'),
28+
},
29+
outputSchema,
30+
annotations: {
31+
readOnlyHint: false,
32+
destructiveHint: false,
33+
},
34+
},
35+
async (args) => {
36+
const record = await ctx.airtableService.uploadAttachment(
37+
args.baseId,
38+
args.recordId,
39+
args.attachmentFieldIdOrName,
40+
args.file,
41+
args.filename,
42+
args.contentType,
43+
);
44+
return jsonResult(outputSchema.parse({id: record.id, fields: record.fields}));
45+
},
46+
);
47+
}

src/types.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,8 @@ export type Comment = z.infer<typeof CommentSchema>;
561561
export type ListCommentsResponse = z.infer<typeof ListCommentsResponseSchema>;
562562

563563
export type FieldSet = Record<string, any>;
564-
export type AirtableRecord = {id: string; fields: FieldSet};
564+
export const AirtableRecordSchema = z.object({id: z.string(), fields: z.record(z.string(), z.any())});
565+
export type AirtableRecord = z.infer<typeof AirtableRecordSchema>;
565566

566567
export type ListRecordsOptions = {
567568
view?: z.infer<typeof ListRecordsArgsSchema.shape.view>;
@@ -585,6 +586,14 @@ export type IAirtableService = {
585586
searchRecords(baseId: string, tableId: string, searchTerm: string, fieldIds?: string[], maxRecords?: number, view?: string): Promise<AirtableRecord[]>;
586587
createComment(baseId: string, tableId: string, recordId: string, text: string, parentCommentId?: string): Promise<Comment>;
587588
listComments(baseId: string, tableId: string, recordId: string, pageSize?: number, offset?: string): Promise<ListCommentsResponse>;
589+
uploadAttachment(
590+
baseId: string,
591+
recordId: string,
592+
attachmentFieldIdOrName: string,
593+
file: string,
594+
filename: string,
595+
contentType: string,
596+
): Promise<AirtableRecord>;
588597
};
589598

590599
export type IAirtableMCPServer = {

0 commit comments

Comments
 (0)