Skip to content

Commit d08f221

Browse files
committed
Modified Workers Binding Server to include R2 object tools and more options for R2 bucket tools.
1 parent ac3ffda commit d08f221

File tree

8 files changed

+804
-16
lines changed

8 files changed

+804
-16
lines changed

apps/workers-bindings/src/bindings.app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { registerDocsTools } from '@repo/mcp-common/src/tools/docs-ai-search.too
1818
import { registerHyperdriveTools } from '@repo/mcp-common/src/tools/hyperdrive.tools'
1919
import { registerKVTools } from '@repo/mcp-common/src/tools/kv_namespace.tools'
2020
import { registerR2BucketTools } from '@repo/mcp-common/src/tools/r2_bucket.tools'
21+
import { registerR2ObjectTools } from '@repo/mcp-common/src/tools/r2_object.tools'
2122
import { registerWorkersTools } from '@repo/mcp-common/src/tools/worker.tools'
2223
import { MetricsTracker } from '@repo/mcp-observability'
2324

@@ -79,6 +80,7 @@ export class WorkersBindingsMCP extends McpAgent<Env, WorkersBindingsMCPState, P
7980
registerKVTools(this)
8081
registerWorkersTools(this)
8182
registerR2BucketTools(this)
83+
registerR2ObjectTools(this)
8284
registerD1Tools(this)
8385
registerHyperdriveTools(this)
8486

packages/mcp-common/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"agents": "0.2.19",
2020
"cloudflare": "4.2.0",
2121
"hono": "4.7.6",
22+
"mime": "4.0.6",
2223
"toucan-js": "4.1.1",
2324
"zod": "3.24.2"
2425
},

packages/mcp-common/src/r2-api.ts

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
/**
2+
* R2 Object API helpers for interacting with R2 objects via the Cloudflare REST API.
3+
* These functions handle raw object content (not JSON) which the Cloudflare SDK doesn't support.
4+
*/
5+
import { env } from 'cloudflare:workers'
6+
7+
const CLOUDFLARE_API_BASE_URL = 'https://api.cloudflare.com/client/v4'
8+
9+
/**
10+
* Helper to get the API token, respecting dev mode
11+
*/
12+
function getApiToken(apiToken: string): string {
13+
// @ts-expect-error We don't have actual env in this package
14+
if (env.DEV_DISABLE_OAUTH) {
15+
// @ts-expect-error We don't have actual env in this package
16+
return env.DEV_CLOUDFLARE_API_TOKEN
17+
}
18+
return apiToken
19+
}
20+
21+
/**
22+
* R2 object metadata returned from get operations
23+
*/
24+
export interface R2ObjectMetadata {
25+
key: string
26+
size: number
27+
etag: string
28+
httpMetadata: {
29+
contentType?: string
30+
contentEncoding?: string
31+
contentDisposition?: string
32+
contentLanguage?: string
33+
cacheControl?: string
34+
expires?: string
35+
}
36+
customMetadata: Record<string, string>
37+
uploaded: string
38+
storageClass: string
39+
}
40+
41+
/**
42+
* Result of an R2 object GET operation
43+
*/
44+
export interface R2ObjectGetResult {
45+
metadata: R2ObjectMetadata
46+
content: string
47+
isBase64: boolean
48+
}
49+
50+
/**
51+
* Fetches an R2 object content and metadata
52+
*/
53+
export async function fetchR2ObjectGet({
54+
accountId,
55+
bucketName,
56+
objectKey,
57+
apiToken,
58+
jurisdiction,
59+
maxSizeBytes,
60+
}: {
61+
accountId: string
62+
bucketName: string
63+
objectKey: string
64+
apiToken: string
65+
jurisdiction?: string
66+
maxSizeBytes?: number
67+
}): Promise<R2ObjectGetResult | null> {
68+
const url = `${CLOUDFLARE_API_BASE_URL}/accounts/${accountId}/r2/buckets/${bucketName}/objects/${encodeURIComponent(objectKey)}`
69+
70+
const headers: Record<string, string> = {
71+
Authorization: `Bearer ${getApiToken(apiToken)}`,
72+
}
73+
74+
if (jurisdiction) {
75+
headers['cf-r2-jurisdiction'] = jurisdiction
76+
}
77+
78+
const response = await fetch(url, {
79+
method: 'GET',
80+
headers,
81+
})
82+
83+
if (response.status === 404) {
84+
return null
85+
}
86+
87+
if (!response.ok) {
88+
const error = await response.text()
89+
throw new Error(`R2 GET request failed: ${error}`)
90+
}
91+
92+
const metadata = parseR2ObjectMetadata(objectKey, response.headers)
93+
94+
// Check size limit
95+
if (maxSizeBytes && metadata.size > maxSizeBytes) {
96+
throw new Error(
97+
`Object size (${metadata.size} bytes) exceeds maximum allowed size (${maxSizeBytes} bytes)`
98+
)
99+
}
100+
101+
// Get content and determine if it should be base64 encoded
102+
const contentType = metadata.httpMetadata.contentType || 'application/octet-stream'
103+
const isTextContent = isTextContentType(contentType)
104+
105+
let content: string
106+
let isBase64: boolean
107+
108+
if (isTextContent) {
109+
content = await response.text()
110+
isBase64 = false
111+
} else {
112+
const arrayBuffer = await response.arrayBuffer()
113+
content = arrayBufferToBase64(arrayBuffer)
114+
isBase64 = true
115+
}
116+
117+
return { metadata, content, isBase64 }
118+
}
119+
120+
/**
121+
* Uploads an R2 object
122+
*/
123+
export async function fetchR2ObjectPut({
124+
accountId,
125+
bucketName,
126+
objectKey,
127+
apiToken,
128+
content,
129+
jurisdiction,
130+
storageClass,
131+
contentType,
132+
contentEncoding,
133+
contentDisposition,
134+
contentLanguage,
135+
cacheControl,
136+
expires,
137+
}: {
138+
accountId: string
139+
bucketName: string
140+
objectKey: string
141+
apiToken: string
142+
content: BodyInit
143+
jurisdiction?: string
144+
storageClass?: string
145+
contentType?: string
146+
contentEncoding?: string
147+
contentDisposition?: string
148+
contentLanguage?: string
149+
cacheControl?: string
150+
expires?: string
151+
}): Promise<{ key: string; uploaded: string }> {
152+
const url = `${CLOUDFLARE_API_BASE_URL}/accounts/${accountId}/r2/buckets/${bucketName}/objects/${encodeURIComponent(objectKey)}`
153+
154+
const headers: Record<string, string> = {
155+
Authorization: `Bearer ${getApiToken(apiToken)}`,
156+
}
157+
158+
if (jurisdiction) {
159+
headers['cf-r2-jurisdiction'] = jurisdiction
160+
}
161+
if (storageClass) {
162+
headers['cf-r2-storage-class'] = storageClass
163+
}
164+
if (contentType) {
165+
headers['Content-Type'] = contentType
166+
}
167+
if (contentEncoding) {
168+
headers['Content-Encoding'] = contentEncoding
169+
}
170+
if (contentDisposition) {
171+
headers['Content-Disposition'] = contentDisposition
172+
}
173+
if (contentLanguage) {
174+
headers['Content-Language'] = contentLanguage
175+
}
176+
if (cacheControl) {
177+
headers['Cache-Control'] = cacheControl
178+
}
179+
if (expires) {
180+
headers['Expires'] = expires
181+
}
182+
183+
const response = await fetch(url, {
184+
method: 'PUT',
185+
headers,
186+
body: content,
187+
})
188+
189+
if (!response.ok) {
190+
const error = await response.text()
191+
throw new Error(`R2 PUT request failed: ${error}`)
192+
}
193+
194+
return {
195+
key: objectKey,
196+
uploaded: new Date().toISOString(),
197+
}
198+
}
199+
200+
/**
201+
* Deletes an R2 object
202+
*/
203+
export async function fetchR2ObjectDelete({
204+
accountId,
205+
bucketName,
206+
objectKey,
207+
apiToken,
208+
jurisdiction,
209+
}: {
210+
accountId: string
211+
bucketName: string
212+
objectKey: string
213+
apiToken: string
214+
jurisdiction?: string
215+
}): Promise<unknown> {
216+
const url = `${CLOUDFLARE_API_BASE_URL}/accounts/${accountId}/r2/buckets/${bucketName}/objects/${encodeURIComponent(objectKey)}`
217+
218+
const headers: Record<string, string> = {
219+
Authorization: `Bearer ${getApiToken(apiToken)}`,
220+
}
221+
222+
if (jurisdiction) {
223+
headers['cf-r2-jurisdiction'] = jurisdiction
224+
}
225+
226+
const response = await fetch(url, {
227+
method: 'DELETE',
228+
headers,
229+
})
230+
231+
if (!response.ok) {
232+
const error = await response.text()
233+
throw new Error(`R2 DELETE request failed: ${error}`)
234+
}
235+
236+
const result = (await response.json()) as { success: boolean; errors?: Array<{ code: number; message: string }> }
237+
238+
if (!result.success) {
239+
const errorMessage = result.errors?.[0]?.message ?? 'Unknown error'
240+
throw new Error(errorMessage)
241+
}
242+
243+
return result
244+
}
245+
246+
/**
247+
* Parse R2 object metadata from response headers
248+
*/
249+
function parseR2ObjectMetadata(objectKey: string, headers: Headers): R2ObjectMetadata {
250+
const customMetadata: Record<string, string> = {}
251+
252+
// Extract custom metadata from x-amz-meta-* headers
253+
headers.forEach((value, key) => {
254+
if (key.toLowerCase().startsWith('x-amz-meta-')) {
255+
const metaKey = key.slice('x-amz-meta-'.length)
256+
customMetadata[metaKey] = value
257+
}
258+
})
259+
260+
return {
261+
key: objectKey,
262+
size: parseInt(headers.get('content-length') || '0', 10),
263+
etag: headers.get('etag') || '',
264+
httpMetadata: {
265+
contentType: headers.get('content-type') || undefined,
266+
contentEncoding: headers.get('content-encoding') || undefined,
267+
contentDisposition: headers.get('content-disposition') || undefined,
268+
contentLanguage: headers.get('content-language') || undefined,
269+
cacheControl: headers.get('cache-control') || undefined,
270+
expires: headers.get('expires') || undefined,
271+
},
272+
customMetadata,
273+
uploaded: headers.get('last-modified') || new Date().toISOString(),
274+
storageClass: headers.get('x-amz-storage-class') || 'Standard',
275+
}
276+
}
277+
278+
/**
279+
* Check if a content type is text-based
280+
*/
281+
function isTextContentType(contentType: string): boolean {
282+
const textTypes = [
283+
'text/',
284+
'application/json',
285+
'application/xml',
286+
'application/javascript',
287+
'application/typescript',
288+
'application/x-www-form-urlencoded',
289+
'application/xhtml+xml',
290+
'application/x-yaml',
291+
'application/yaml',
292+
'application/toml',
293+
'application/graphql',
294+
'application/ld+json',
295+
'application/manifest+json',
296+
'application/schema+json',
297+
'application/sql',
298+
'application/x-sh',
299+
]
300+
301+
const lowerContentType = contentType.toLowerCase()
302+
return textTypes.some((type) => lowerContentType.startsWith(type))
303+
}
304+
305+
/**
306+
* Convert ArrayBuffer to base64 string
307+
*/
308+
function arrayBufferToBase64(buffer: ArrayBuffer): string {
309+
const bytes = new Uint8Array(buffer)
310+
let binary = ''
311+
for (let i = 0; i < bytes.length; i++) {
312+
binary += String.fromCharCode(bytes[i])
313+
}
314+
return btoa(binary)
315+
}
316+
317+
/**
318+
* Convert base64 string to Uint8Array
319+
*/
320+
export function base64ToUint8Array(base64: string): Uint8Array {
321+
const binary = atob(base64)
322+
const bytes = new Uint8Array(binary.length)
323+
for (let i = 0; i < binary.length; i++) {
324+
bytes[i] = binary.charCodeAt(i)
325+
}
326+
return bytes
327+
}

0 commit comments

Comments
 (0)