From 87b3045dfa0cf0041769498ac1700715f852bb58 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 17 Mar 2026 16:03:18 -0300 Subject: [PATCH 1/2] feat(filesystem): add first-class S3-backed filesystem support Add complete filesystem/object storage integration for AI agents: - New FILESYSTEM_BINDING with 5 tools (Read, Write, List, Delete, Metadata) - S3Service singleton with 1MB inline read limit, base64 binary encoding - Path sanitization with null byte/traversal attack protection - Well-known filesystem connection (org-scoped, fixed, HTTP type) - Agent UI toggle to enable/disable filesystem access per agent - Environment-based S3 configuration (single shared bucket per mesh) - Organization isolation via org-ID prefix in S3 keys - MCP server implementation handling both direct calls and MCP protocol Filesystem is a separate connection (not core tools) for selective agent access. Org-ID prefix ensures immutable isolation (unlike org slug which can change). Co-Authored-By: Claude Haiku 4.5 --- apps/mesh/package.json | 1 + apps/mesh/src/api/app.ts | 43 ++- apps/mesh/src/api/routes/filesystem-mcp.ts | 306 ++++++++++++++++++ apps/mesh/src/env.ts | 18 ++ apps/mesh/src/filesystem/factory.ts | 58 ++++ apps/mesh/src/filesystem/path-utils.test.ts | 173 ++++++++++ apps/mesh/src/filesystem/path-utils.ts | 160 +++++++++ apps/mesh/src/filesystem/s3-service.ts | 269 +++++++++++++++ apps/mesh/src/tools/connection/filesystem.ts | 82 +++++ apps/mesh/src/tools/connection/get.ts | 15 + apps/mesh/src/tools/connection/list.ts | 20 ++ apps/mesh/src/tools/filesystem/index.ts | 10 + apps/mesh/src/tools/filesystem/schema.ts | 29 ++ .../components/details/virtual-mcp/index.tsx | 74 +++++ packages/bindings/package.json | 1 + packages/bindings/src/index.ts | 26 ++ .../bindings/src/well-known/filesystem.ts | 234 ++++++++++++++ packages/mesh-sdk/src/index.ts | 2 + packages/mesh-sdk/src/lib/constants.ts | 44 +++ 19 files changed, 1564 insertions(+), 1 deletion(-) create mode 100644 apps/mesh/src/api/routes/filesystem-mcp.ts create mode 100644 apps/mesh/src/filesystem/factory.ts create mode 100644 apps/mesh/src/filesystem/path-utils.test.ts create mode 100644 apps/mesh/src/filesystem/path-utils.ts create mode 100644 apps/mesh/src/filesystem/s3-service.ts create mode 100644 apps/mesh/src/tools/connection/filesystem.ts create mode 100644 apps/mesh/src/tools/filesystem/index.ts create mode 100644 apps/mesh/src/tools/filesystem/schema.ts create mode 100644 packages/bindings/src/well-known/filesystem.ts diff --git a/apps/mesh/package.json b/apps/mesh/package.json index d0957ad6e8..2ebe42e1f7 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -58,6 +58,7 @@ "devDependencies": { "@ai-sdk/provider": "^3.0.8", "@ai-sdk/react": "^3.0.103", + "@aws-sdk/client-s3": "^3.1010.0", "@better-auth/sso": "1.4.1", "@daveyplate/better-auth-ui": "^3.2.7", "@deco/ui": "workspace:*", diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index 526c140a35..301fb99f03 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -12,7 +12,7 @@ import { env } from "../env"; import { DECO_STORE_URL, isDecoHostedMcp } from "@/core/deco-constants"; import { WellKnownOrgMCPId } from "@decocms/mesh-sdk"; import { PrometheusSerializer } from "@opentelemetry/exporter-prometheus"; -import { Hono } from "hono"; +import { type Context, Hono } from "hono"; import { getCookie } from "hono/cookie"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; @@ -971,6 +971,47 @@ export async function createApp(options: CreateAppOptions = {}) { mountDevRoutes(app, mcpAuth); } + // Filesystem MCP routes (S3-backed filesystem for agents) + // Handle /mcp/filesystem alias + app.use("/mcp/filesystem", mcpAuth); + app.route( + "/mcp/filesystem", + (await import("./routes/filesystem-mcp")).default, + ); + + // Handle {org_id}_filesystem connection ID pattern + app.all( + "/mcp/:connectionId{.*_filesystem$}", + mcpAuth, + async (c: Context) => { + const ctx = c.get("meshContext") as MeshContext; + const { handleFilesystemMcpRequest } = await import( + "./routes/filesystem-mcp" + ); + return handleFilesystemMcpRequest(c.req.raw, ctx); + }, + ); + + // Handle call-tool endpoint for filesystem connections + app.all( + "/mcp/:connectionId{.*_filesystem$}/call-tool/:toolName", + mcpAuth, + async (c: Context) => { + const ctx = c.get("meshContext") as MeshContext; + const toolName = c.req.param("toolName"); + if (!toolName) { + return c.json({ error: "Missing tool name" }, 400); + } + const args = (await c.req.json()) as Record; + const { callFilesystemTool } = await import("./routes/filesystem-mcp"); + const result = await callFilesystemTool(toolName, args, ctx); + if (result.isError) { + return c.json(result.content, 500); + } + return c.json(result.content); + }, + ); + // Virtual MCP / Agent routes (must be before proxy to match /mcp/gateway and /mcp/virtual-mcp before /mcp/:connectionId) // /mcp/gateway/:virtualMcpId (backward compat) or /mcp/virtual-mcp/:virtualMcpId app.route("/mcp", virtualMcpRoutes); diff --git a/apps/mesh/src/api/routes/filesystem-mcp.ts b/apps/mesh/src/api/routes/filesystem-mcp.ts new file mode 100644 index 0000000000..e48e74740b --- /dev/null +++ b/apps/mesh/src/api/routes/filesystem-mcp.ts @@ -0,0 +1,306 @@ +/** + * Filesystem MCP Server + * + * An MCP server that implements the FILESYSTEM_BINDING interface + * using S3-compatible storage as the backing store. + * + * Provides inline content access for AI agents (read/write file content + * directly in tool calls), unlike OBJECT_STORAGE_BINDING which uses presigned URLs. + * + * Route: POST /mcp/filesystem + * Also handles org-scoped connection ID pattern: /mcp/{orgId}_filesystem + */ + +import type { + FsDeleteInput, + FsDeleteOutput, + FsListInput, + FsListOutput, + FsMetadataInput, + FsMetadataOutput, + FsReadInput, + FsReadOutput, + FsWriteInput, + FsWriteOutput, +} from "@decocms/bindings/filesystem"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { Hono } from "hono"; +import { z } from "zod"; +import type { MeshContext } from "../../core/mesh-context"; +import { requireOrganization } from "../../core/mesh-context"; +import { S3Service } from "../../filesystem/s3-service"; +import { + FsDeleteInputSchema, + FsDeleteOutputSchema, + FsListInputSchema, + FsListOutputSchema, + FsMetadataInputSchema, + FsMetadataOutputSchema, + FsReadInputSchema, + FsReadOutputSchema, + FsWriteInputSchema, + FsWriteOutputSchema, +} from "../../tools/filesystem/schema"; +import { getFilesystemS3Service } from "../../filesystem/factory"; + +// Local tool definition type +interface ToolDefinition { + name: string; + description?: string; + inputSchema: z.ZodTypeAny; + outputSchema?: z.ZodTypeAny; + handler: (args: Record) => Promise; + annotations?: { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + }; +} + +// Define Hono variables type +type Variables = { + meshContext: MeshContext; +}; + +const app = new Hono<{ Variables: Variables }>(); + +function createFilesystemTools(s3: S3Service, orgId: string): ToolDefinition[] { + return [ + { + name: "FS_READ", + description: + "Read file content. Returns content inline for text files and small binary files (as base64). Returns an error for files exceeding the size limit.", + inputSchema: FsReadInputSchema, + outputSchema: FsReadOutputSchema, + annotations: { + title: "Read File", + readOnlyHint: true, + destructiveHint: false, + }, + handler: async (args): Promise => { + const input = args as FsReadInput; + return s3.readFile(orgId, input.path, input.offset, input.limit); + }, + }, + { + name: "FS_WRITE", + description: + "Write content to a file. Creates the file if it doesn't exist, overwrites if it does.", + inputSchema: FsWriteInputSchema, + outputSchema: FsWriteOutputSchema, + annotations: { + title: "Write File", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + handler: async (args): Promise => { + const input = args as FsWriteInput; + return s3.writeFile( + orgId, + input.path, + input.content, + input.encoding, + input.contentType, + ); + }, + }, + { + name: "FS_LIST", + description: + "List files and directories at a given path. Supports pagination and glob pattern filtering on file names.", + inputSchema: FsListInputSchema, + outputSchema: FsListOutputSchema, + annotations: { + title: "List Files", + readOnlyHint: true, + destructiveHint: false, + }, + handler: async (args): Promise => { + const input = args as FsListInput; + return s3.listFiles(orgId, input); + }, + }, + { + name: "FS_DELETE", + description: "Delete a single file.", + inputSchema: FsDeleteInputSchema, + outputSchema: FsDeleteOutputSchema, + annotations: { + title: "Delete File", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + }, + handler: async (args): Promise => { + const input = args as FsDeleteInput; + return s3.deleteFile(orgId, input.path); + }, + }, + { + name: "FS_METADATA", + description: + "Get file metadata including size, content type, last modified time, and ETag.", + inputSchema: FsMetadataInputSchema, + outputSchema: FsMetadataOutputSchema, + annotations: { + title: "File Metadata", + readOnlyHint: true, + destructiveHint: false, + }, + handler: async (args): Promise => { + const input = args as FsMetadataInput; + return s3.getMetadata(orgId, input.path); + }, + }, + ]; +} + +/** + * Handle a filesystem MCP request with a given context + */ +export async function handleFilesystemMcpRequest( + req: Request, + ctx: MeshContext, +): Promise { + const org = requireOrganization(ctx); + const s3 = getFilesystemS3Service(); + + if (!s3) { + return new Response( + JSON.stringify({ + error: "Filesystem not configured. Set S3_* environment variables.", + }), + { status: 503, headers: { "Content-Type": "application/json" } }, + ); + } + + const tools = createFilesystemTools(s3, org.id); + + const server = new McpServer( + { name: "filesystem-mcp", version: "1.0.0" }, + { capabilities: { tools: {} } }, + ); + + for (const tool of tools) { + const inputShape = + "shape" in tool.inputSchema + ? (tool.inputSchema.shape as z.ZodRawShape) + : z.object({}).shape; + const outputShape = + tool.outputSchema && "shape" in tool.outputSchema + ? (tool.outputSchema.shape as z.ZodRawShape) + : z.object({}).shape; + + server.registerTool( + tool.name, + { + description: tool.description ?? "", + inputSchema: inputShape, + outputSchema: outputShape, + annotations: tool.annotations, + }, + async (args) => { + try { + const result = await tool.handler(args); + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + structuredContent: result as { [x: string]: unknown }, + }; + } catch (error) { + const err = error as Error; + return { + content: [{ type: "text" as const, text: `Error: ${err.message}` }], + isError: true, + }; + } + }, + ); + } + + const transport = new WebStandardStreamableHTTPServerTransport({ + enableJsonResponse: + req.headers.get("Accept")?.includes("application/json") ?? false, + }); + await server.connect(transport); + return transport.handleRequest(req); +} + +/** + * Call a filesystem tool directly + */ +export async function callFilesystemTool( + toolName: string, + args: Record, + ctx: MeshContext, +): Promise<{ content: unknown; isError?: boolean }> { + const org = requireOrganization(ctx); + const s3 = getFilesystemS3Service(); + + if (!s3) { + return { + content: [ + { + type: "text", + text: "Filesystem not configured. Set S3_* environment variables.", + }, + ], + isError: true, + }; + } + + const tools = createFilesystemTools(s3, org.id); + const tool = tools.find((t) => t.name === toolName); + + if (!tool) { + return { + content: [{ type: "text", text: `Tool not found: ${toolName}` }], + isError: true, + }; + } + + const parsed = tool.inputSchema.safeParse(args); + if (!parsed.success) { + return { + content: [ + { + type: "text", + text: `Invalid input: ${parsed.error.message}`, + }, + ], + isError: true, + }; + } + + try { + const result = await tool.handler(parsed.data as Record); + return { + content: [{ type: "text", text: JSON.stringify(result) }], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } +} + +/** + * Filesystem MCP endpoint + * + * Route: POST /mcp/filesystem + */ +app.all("/", async (c) => { + const ctx = c.get("meshContext"); + return handleFilesystemMcpRequest(c.req.raw, ctx); +}); + +export default app; diff --git a/apps/mesh/src/env.ts b/apps/mesh/src/env.ts index ee5763af45..4330b96c56 100644 --- a/apps/mesh/src/env.ts +++ b/apps/mesh/src/env.ts @@ -44,6 +44,14 @@ const envSchema = z // Transport UNSAFE_ALLOW_STDIO_TRANSPORT: zBooleanString, + // Object Storage (S3-compatible) + S3_ENDPOINT: z.string().optional(), + S3_BUCKET: z.string().optional(), + S3_REGION: z.string().default("auto"), + S3_ACCESS_KEY_ID: z.string().optional(), + S3_SECRET_ACCESS_KEY: z.string().optional(), + S3_FORCE_PATH_STYLE: z.enum(["true", "false"]).optional().default("true"), + // Debug / K8s DEBUG_PORT: z.coerce.number().default(9090), ENABLE_DEBUG_SERVER: zBooleanString, @@ -95,6 +103,8 @@ const SECRET_KEYS = new Set([ "BETTER_AUTH_SECRET", "ENCRYPTION_KEY", "MESH_JWT_SECRET", + "S3_ACCESS_KEY_ID", + "S3_SECRET_ACCESS_KEY", ]); const URL_KEYS = new Set(["DATABASE_URL", "CLICKHOUSE_URL", "NATS_URL"]); @@ -174,6 +184,14 @@ function logConfiguration(e: Env) { section("Transport"); row("UNSAFE_ALLOW_STDIO_TRANSPORT", e.UNSAFE_ALLOW_STDIO_TRANSPORT); + section("Object Storage"); + row("S3_ENDPOINT", e.S3_ENDPOINT); + row("S3_BUCKET", e.S3_BUCKET); + row("S3_REGION", e.S3_REGION); + row("S3_ACCESS_KEY_ID", e.S3_ACCESS_KEY_ID); + row("S3_SECRET_ACCESS_KEY", e.S3_SECRET_ACCESS_KEY); + row("S3_FORCE_PATH_STYLE", e.S3_FORCE_PATH_STYLE); + section("Debug / K8s"); row("DEBUG_PORT", e.DEBUG_PORT); row("ENABLE_DEBUG_SERVER", e.ENABLE_DEBUG_SERVER); diff --git a/apps/mesh/src/filesystem/factory.ts b/apps/mesh/src/filesystem/factory.ts new file mode 100644 index 0000000000..595b7b6c85 --- /dev/null +++ b/apps/mesh/src/filesystem/factory.ts @@ -0,0 +1,58 @@ +/** + * Filesystem S3 Service Factory + * + * Creates and caches a singleton S3Service instance from environment variables. + * For v1, only mesh-level S3 config via env vars is supported. + */ + +import { S3Service } from "./s3-service"; + +let cachedService: S3Service | null = null; +let initialized = false; + +/** + * Get the mesh-level filesystem S3 service. + * Returns null if S3 is not configured (S3_* env vars not set). + * + * The service is created once and cached for the lifetime of the process. + */ +export function getFilesystemS3Service(): S3Service | null { + if (initialized) { + return cachedService; + } + + initialized = true; + + const endpoint = process.env.S3_ENDPOINT; + const bucket = process.env.S3_BUCKET; + const region = process.env.S3_REGION ?? "auto"; + const accessKeyId = process.env.S3_ACCESS_KEY_ID; + const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY; + + if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) { + return null; + } + + cachedService = new S3Service({ + endpoint, + bucket, + region, + accessKeyId, + secretAccessKey, + forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== "false", + }); + + return cachedService; +} + +/** + * Check if filesystem is configured (S3 env vars are set). + */ +export function isFilesystemConfigured(): boolean { + return !!( + process.env.S3_ENDPOINT && + process.env.S3_BUCKET && + process.env.S3_ACCESS_KEY_ID && + process.env.S3_SECRET_ACCESS_KEY + ); +} diff --git a/apps/mesh/src/filesystem/path-utils.test.ts b/apps/mesh/src/filesystem/path-utils.test.ts new file mode 100644 index 0000000000..9f953515fa --- /dev/null +++ b/apps/mesh/src/filesystem/path-utils.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "bun:test"; +import { + buildS3Key, + buildS3Prefix, + detectContentType, + isTextContentType, + sanitizePath, + stripOrgPrefix, +} from "./path-utils"; + +describe("sanitizePath", () => { + it("strips leading slashes", () => { + expect(sanitizePath("/foo/bar")).toBe("foo/bar"); + expect(sanitizePath("///foo")).toBe("foo"); + }); + + it("strips trailing slashes", () => { + expect(sanitizePath("foo/bar/")).toBe("foo/bar"); + }); + + it("removes .. segments", () => { + expect(sanitizePath("../../../etc/passwd")).toBe("etc/passwd"); + expect(sanitizePath("foo/../bar")).toBe("foo/bar"); + expect(sanitizePath("foo/../../bar")).toBe("foo/bar"); + }); + + it("removes . segments", () => { + expect(sanitizePath("./foo/./bar")).toBe("foo/bar"); + }); + + it("removes null bytes", () => { + expect(sanitizePath("file\0.txt")).toBe("file.txt"); + }); + + it("removes non-printable characters", () => { + expect(sanitizePath("file\x01\x02.txt")).toBe("file.txt"); + }); + + it("normalizes backslashes", () => { + expect(sanitizePath("foo\\bar\\baz")).toBe("foo/bar/baz"); + }); + + it("normalizes multiple slashes", () => { + expect(sanitizePath("foo//bar///baz")).toBe("foo/bar/baz"); + }); + + it("handles percent-encoded traversal", () => { + expect(sanitizePath("%2e%2e%2ffoo")).toBe("foo"); + }); + + it("strips percent-encoded null bytes", () => { + expect(sanitizePath("file%00.txt")).toBe("file.txt"); + }); + + it("handles malformed percent encoding gracefully", () => { + expect(sanitizePath("file%ZZ.txt")).toBe("file%ZZ.txt"); + }); + + it("handles empty string", () => { + expect(sanitizePath("")).toBe(""); + }); + + it("handles just dots", () => { + expect(sanitizePath("..")).toBe(""); + expect(sanitizePath(".")).toBe(""); + }); + + it("preserves normal paths", () => { + expect(sanitizePath("docs/readme.md")).toBe("docs/readme.md"); + expect(sanitizePath("src/components/Button.tsx")).toBe( + "src/components/Button.tsx", + ); + }); + + it("handles paths with spaces", () => { + expect(sanitizePath("my folder/my file.txt")).toBe("my folder/my file.txt"); + }); +}); + +describe("buildS3Key", () => { + it("prefixes with org ID", () => { + expect(buildS3Key("org_123", "docs/readme.md")).toBe( + "org_123/docs/readme.md", + ); + }); + + it("sanitizes the path", () => { + expect(buildS3Key("org_123", "../../../etc/passwd")).toBe( + "org_123/etc/passwd", + ); + }); + + it("throws for empty path", () => { + expect(() => buildS3Key("org_123", "")).toThrow("Path cannot be empty"); + expect(() => buildS3Key("org_123", "..")).toThrow("Path cannot be empty"); + }); + + it("prevents escaping org prefix via traversal", () => { + const key = buildS3Key("org_a", "../../org_b/secret.txt"); + expect(key).toBe("org_a/org_b/secret.txt"); + expect(key.startsWith("org_a/")).toBe(true); + }); +}); + +describe("buildS3Prefix", () => { + it("returns org root when no path given", () => { + expect(buildS3Prefix("org_123")).toBe("org_123/"); + expect(buildS3Prefix("org_123", undefined)).toBe("org_123/"); + expect(buildS3Prefix("org_123", "")).toBe("org_123/"); + }); + + it("appends trailing slash to directory path", () => { + expect(buildS3Prefix("org_123", "docs")).toBe("org_123/docs/"); + }); + + it("preserves trailing slash", () => { + expect(buildS3Prefix("org_123", "docs/")).toBe("org_123/docs/"); + }); +}); + +describe("stripOrgPrefix", () => { + it("strips the org prefix", () => { + expect(stripOrgPrefix("org_123", "org_123/docs/readme.md")).toBe( + "docs/readme.md", + ); + }); + + it("returns key unchanged if prefix doesn't match", () => { + expect(stripOrgPrefix("org_123", "other/readme.md")).toBe( + "other/readme.md", + ); + }); +}); + +describe("detectContentType", () => { + it("detects text types", () => { + expect(detectContentType("readme.md")).toBe("text/markdown"); + expect(detectContentType("style.css")).toBe("text/css"); + expect(detectContentType("index.html")).toBe("text/html"); + }); + + it("detects code types", () => { + expect(detectContentType("app.ts")).toBe("text/typescript"); + expect(detectContentType("config.json")).toBe("application/json"); + expect(detectContentType("script.js")).toBe("application/javascript"); + }); + + it("detects image types", () => { + expect(detectContentType("photo.png")).toBe("image/png"); + expect(detectContentType("photo.jpg")).toBe("image/jpeg"); + }); + + it("defaults to octet-stream for unknown", () => { + expect(detectContentType("file.xyz")).toBe("application/octet-stream"); + expect(detectContentType("noextension")).toBe("application/octet-stream"); + }); +}); + +describe("isTextContentType", () => { + it("returns true for text types", () => { + expect(isTextContentType("text/plain")).toBe(true); + expect(isTextContentType("text/markdown")).toBe(true); + expect(isTextContentType("application/json")).toBe(true); + expect(isTextContentType("application/javascript")).toBe(true); + expect(isTextContentType("image/svg+xml")).toBe(true); + }); + + it("returns false for binary types", () => { + expect(isTextContentType("image/png")).toBe(false); + expect(isTextContentType("application/octet-stream")).toBe(false); + expect(isTextContentType("application/pdf")).toBe(false); + }); +}); diff --git a/apps/mesh/src/filesystem/path-utils.ts b/apps/mesh/src/filesystem/path-utils.ts new file mode 100644 index 0000000000..1f7a37e9ad --- /dev/null +++ b/apps/mesh/src/filesystem/path-utils.ts @@ -0,0 +1,160 @@ +/** + * Path Utilities for Filesystem Operations + * + * Provides path sanitization and S3 key construction with org-level isolation. + * All user-provided paths are sanitized to prevent directory traversal attacks. + */ + +/** + * Sanitize a user-provided file path to prevent directory traversal and injection attacks. + * + * - Strips leading/trailing slashes + * - Removes `..` path segments + * - Removes null bytes + * - Removes non-printable characters + * - Normalizes multiple consecutive slashes + * - Rejects empty paths after sanitization + */ +export function sanitizePath(userPath: string): string { + let path = userPath; + + // Decode percent-encoded sequences first so encoded null bytes / control chars + // don't survive past the stripping step below. + try { + path = decodeURIComponent(path); + } catch { + // If decoding fails (malformed %), continue with the raw string + } + + // Remove null bytes + path = path.replace(/\0/g, ""); + + // Remove non-printable characters (control chars) + path = path.replace(/[\x00-\x1f\x7f]/g, ""); + + // Normalize backslashes to forward slashes + path = path.replace(/\\/g, "/"); + + // Remove leading/trailing slashes + path = path.replace(/^\/+|\/+$/g, ""); + + // Split into segments, remove '..' and '.' segments + const segments = path + .split("/") + .filter((s) => s !== ".." && s !== "." && s !== ""); + + // Rejoin and normalize multiple slashes + path = segments.join("/"); + + return path; +} + +/** + * Build an S3 key from an org ID and user-provided path. + * For the mesh-default shared bucket, the key is prefixed with the org ID. + * + * @param orgId - Organization ID (immutable, used as prefix) + * @param userPath - User-provided file path + * @returns The full S3 key with org prefix + */ +export function buildS3Key(orgId: string, userPath: string): string { + const sanitized = sanitizePath(userPath); + if (!sanitized) { + throw new Error("Path cannot be empty"); + } + return `${orgId}/${sanitized}`; +} + +/** + * Build an S3 prefix for listing operations. + * If no path is provided, returns the org root prefix. + * + * @param orgId - Organization ID + * @param userPath - Optional directory path + * @returns The S3 prefix for listing + */ +export function buildS3Prefix(orgId: string, userPath?: string): string { + if (!userPath) { + return `${orgId}/`; + } + const sanitized = sanitizePath(userPath); + if (!sanitized) { + return `${orgId}/`; + } + // Ensure prefix ends with / + return `${orgId}/${sanitized}${sanitized.endsWith("/") ? "" : "/"}`; +} + +/** + * Strip the org prefix from an S3 key to get the user-visible path. + * + * @param orgId - Organization ID + * @param s3Key - Full S3 key + * @returns The user-visible path without org prefix + */ +export function stripOrgPrefix(orgId: string, s3Key: string): string { + const prefix = `${orgId}/`; + if (s3Key.startsWith(prefix)) { + return s3Key.slice(prefix.length); + } + return s3Key; +} + +/** + * Detect content type from file extension. + * Returns a reasonable default for common file types. + */ +export function detectContentType(path: string): string { + const ext = path.split(".").pop()?.toLowerCase(); + const types: Record = { + // Text + txt: "text/plain", + md: "text/markdown", + html: "text/html", + css: "text/css", + csv: "text/csv", + xml: "text/xml", + // Code + js: "application/javascript", + mjs: "application/javascript", + ts: "text/typescript", + tsx: "text/typescript", + jsx: "application/javascript", + json: "application/json", + yaml: "application/yaml", + yml: "application/yaml", + toml: "application/toml", + // Images + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + svg: "image/svg+xml", + webp: "image/webp", + ico: "image/x-icon", + // Documents + pdf: "application/pdf", + // Archives + zip: "application/zip", + gz: "application/gzip", + tar: "application/x-tar", + // Data + wasm: "application/wasm", + }; + return types[ext ?? ""] ?? "application/octet-stream"; +} + +/** + * Check if a content type is text-based (content can be returned as utf-8). + */ +export function isTextContentType(contentType: string): boolean { + return ( + contentType.startsWith("text/") || + contentType === "application/json" || + contentType === "application/javascript" || + contentType === "application/yaml" || + contentType === "application/toml" || + contentType === "application/xml" || + contentType === "image/svg+xml" + ); +} diff --git a/apps/mesh/src/filesystem/s3-service.ts b/apps/mesh/src/filesystem/s3-service.ts new file mode 100644 index 0000000000..fbaa5cf625 --- /dev/null +++ b/apps/mesh/src/filesystem/s3-service.ts @@ -0,0 +1,269 @@ +/** + * S3 Service + * + * Wraps @aws-sdk/client-s3 with org-scoped path isolation. + * Provides filesystem-like operations (read, write, list, delete, metadata) + * backed by any S3-compatible storage (AWS S3, Cloudflare R2, MinIO, Backblaze B2). + */ + +import { + DeleteObjectCommand, + GetObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { + buildS3Key, + buildS3Prefix, + detectContentType, + isTextContentType, + stripOrgPrefix, +} from "./path-utils"; +import type { + FsDeleteOutput, + FsListInput, + FsListOutput, + FsMetadataOutput, + FsReadOutput, + FsWriteOutput, +} from "@decocms/bindings/filesystem"; + +/** Maximum file size for inline content reads (1 MB) */ +const MAX_INLINE_READ_SIZE = 1 * 1024 * 1024; + +export interface S3Config { + endpoint: string; + bucket: string; + region: string; + accessKeyId: string; + secretAccessKey: string; + forcePathStyle?: boolean; +} + +export class S3Service { + private client: S3Client; + private bucket: string; + + constructor(config: S3Config) { + this.bucket = config.bucket; + this.client = new S3Client({ + endpoint: config.endpoint, + region: config.region, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + forcePathStyle: config.forcePathStyle ?? true, + }); + } + + async readFile( + orgId: string, + path: string, + offset?: number, + limit?: number, + ): Promise { + const key = buildS3Key(orgId, path); + + // First, HEAD to get metadata + const head = await this.client + .send(new HeadObjectCommand({ Bucket: this.bucket, Key: key })) + .catch( + (err: { name?: string; $metadata?: { httpStatusCode?: number } }) => { + if ( + err.name === "NotFound" || + err.$metadata?.httpStatusCode === 404 + ) { + return null; + } + throw err; + }, + ); + + if (!head) { + return { size: 0, error: "FILE_NOT_FOUND" }; + } + + const size = head.ContentLength ?? 0; + const contentType = head.ContentType ?? detectContentType(path); + + // Check size limit for full reads (partial reads are always allowed) + if (!offset && !limit && size > MAX_INLINE_READ_SIZE) { + return { size, contentType, error: "FILE_TOO_LARGE" }; + } + + // Build range header for partial reads + let range: string | undefined; + if (offset !== undefined || limit !== undefined) { + const start = offset ?? 0; + const end = limit !== undefined ? start + limit - 1 : ""; + range = `bytes=${start}-${end}`; + } + + const response = await this.client.send( + new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + Range: range, + }), + ); + + if (!response.Body) { + return { size, contentType, error: "FILE_NOT_FOUND" }; + } + + const isText = isTextContentType(contentType); + + if (isText) { + const content = await response.Body.transformToString("utf-8"); + return { content, encoding: "utf-8", contentType, size }; + } + + // Binary content — return as base64 + const bytes = await response.Body.transformToByteArray(); + const content = Buffer.from(bytes).toString("base64"); + return { content, encoding: "base64", contentType, size }; + } + + async writeFile( + orgId: string, + path: string, + content: string, + encoding: "utf-8" | "base64" = "utf-8", + contentType?: string, + ): Promise { + const key = buildS3Key(orgId, path); + const resolvedContentType = contentType ?? detectContentType(path); + + let body: Buffer; + if (encoding === "base64") { + body = Buffer.from(content, "base64"); + } else { + body = Buffer.from(content, "utf-8"); + } + + await this.client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: body, + ContentType: resolvedContentType, + }), + ); + + return { path, size: body.length }; + } + + async listFiles(orgId: string, input: FsListInput): Promise { + const prefix = buildS3Prefix(orgId, input.path); + const maxKeys = Math.min(input.maxKeys ?? 100, 1000); + + const response = await this.client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: prefix, + Delimiter: "/", + MaxKeys: maxKeys, + ContinuationToken: input.continuationToken, + }), + ); + + const entries: FsListOutput["entries"] = []; + + // Add directories (common prefixes) + if (response.CommonPrefixes) { + for (const cp of response.CommonPrefixes) { + if (cp.Prefix) { + const dirPath = stripOrgPrefix(orgId, cp.Prefix); + // Filter by pattern if provided + if (input.pattern && !matchGlob(dirPath, input.pattern)) { + continue; + } + entries.push({ path: dirPath, type: "directory" }); + } + } + } + + // Add files + if (response.Contents) { + for (const obj of response.Contents) { + if (!obj.Key) continue; + // Skip the prefix itself (S3 can return the directory marker) + if (obj.Key === prefix) continue; + + const filePath = stripOrgPrefix(orgId, obj.Key); + // Filter by pattern if provided + if (input.pattern && !matchGlob(filePath, input.pattern)) { + continue; + } + entries.push({ + path: filePath, + type: "file", + size: obj.Size, + lastModified: obj.LastModified?.toISOString(), + }); + } + } + + return { + entries, + isTruncated: response.IsTruncated ?? false, + nextContinuationToken: response.NextContinuationToken, + }; + } + + async deleteFile(orgId: string, path: string): Promise { + const key = buildS3Key(orgId, path); + + await this.client.send( + new DeleteObjectCommand({ Bucket: this.bucket, Key: key }), + ); + + // S3 DeleteObject is idempotent (returns 204 even for nonexistent keys) + return { success: true, path }; + } + + async getMetadata(orgId: string, path: string): Promise { + const key = buildS3Key(orgId, path); + + const head = await this.client.send( + new HeadObjectCommand({ Bucket: this.bucket, Key: key }), + ); + + return { + size: head.ContentLength ?? 0, + contentType: head.ContentType ?? detectContentType(path), + lastModified: + head.LastModified?.toISOString() ?? new Date().toISOString(), + etag: head.ETag ?? "", + }; + } + + destroy(): void { + this.client.destroy(); + } +} + +/** + * Simple glob pattern matching for filtering file paths. + * Supports * (any chars except /) and ** (any chars including /). + */ +function matchGlob(path: string, pattern: string): boolean { + // Strip trailing slash so directory entries (e.g. "docs/") match by name + const normalized = path.endsWith("/") ? path.slice(0, -1) : path; + // Get just the filename for patterns without / + const target = pattern.includes("/") + ? normalized + : (normalized.split("/").pop() ?? normalized); + + const regexStr = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*\*/g, "{{GLOBSTAR}}") + .replace(/\*/g, "[^/]*") + .replace(/{{GLOBSTAR}}/g, ".*") + .replace(/\?/g, "[^/]"); + + return new RegExp(`^${regexStr}$`).test(target); +} diff --git a/apps/mesh/src/tools/connection/filesystem.ts b/apps/mesh/src/tools/connection/filesystem.ts new file mode 100644 index 0000000000..e016214c5a --- /dev/null +++ b/apps/mesh/src/tools/connection/filesystem.ts @@ -0,0 +1,82 @@ +/** + * Filesystem Connection Utilities + * + * Shared utilities for the S3-backed filesystem connection. + * This connection is injected when S3 is configured to provide + * filesystem functionality for AI agents. + */ + +import { FILESYSTEM_BINDING } from "@decocms/bindings/filesystem"; +import { + getWellKnownFilesystemConnection, + WellKnownOrgMCPId, +} from "@decocms/mesh-sdk"; +import { z } from "zod"; +import { isFilesystemConfigured } from "../../filesystem/factory"; +import { type ConnectionEntity, type ToolDefinition } from "./schema"; + +/** + * Cached tool definitions for filesystem connection. + * Computed once at module load time to avoid repeated z.toJSONSchema conversions. + */ +const FILESYSTEM_TOOLS: ToolDefinition[] = FILESYSTEM_BINDING.map( + (binding: (typeof FILESYSTEM_BINDING)[number]) => ({ + name: binding.name, + description: `${binding.name} operation for S3-backed filesystem`, + inputSchema: z.toJSONSchema(binding.inputSchema) as Record, + outputSchema: z.toJSONSchema(binding.outputSchema) as Record< + string, + unknown + >, + }), +); + +/** + * Check if a connection ID is the filesystem connection for an organization + */ +export function isFilesystemConnection( + connectionId: string, + organizationId: string, +): boolean { + return connectionId === WellKnownOrgMCPId.FILESYSTEM(organizationId); +} + +/** + * Create a filesystem connection entity for S3-backed file storage. + * This is injected when S3 is configured to provide filesystem + * functionality for AI agents. + */ +export function createFilesystemConnectionEntity( + orgId: string, + baseUrl: string, +): ConnectionEntity { + const connectionData = getWellKnownFilesystemConnection(baseUrl, orgId); + + const now = new Date().toISOString(); + + return { + id: connectionData.id ?? WellKnownOrgMCPId.FILESYSTEM(orgId), + title: connectionData.title, + description: connectionData.description ?? null, + icon: connectionData.icon ?? null, + app_name: connectionData.app_name ?? null, + app_id: connectionData.app_id ?? null, + organization_id: orgId, + created_by: "system", + created_at: now, + updated_at: now, + connection_type: connectionData.connection_type, + connection_url: connectionData.connection_url ?? null, + connection_token: null, + connection_headers: null, + oauth_config: null, + configuration_state: null, + configuration_scopes: null, + metadata: connectionData.metadata ?? null, + tools: FILESYSTEM_TOOLS, + bindings: ["FILESYSTEM"], + status: "active", + }; +} + +export { isFilesystemConfigured }; diff --git a/apps/mesh/src/tools/connection/get.ts b/apps/mesh/src/tools/connection/get.ts index e03f40fd36..77d97ed6a1 100644 --- a/apps/mesh/src/tools/connection/get.ts +++ b/apps/mesh/src/tools/connection/get.ts @@ -16,6 +16,11 @@ import { isDevAssetsConnection, isDevMode, } from "./dev-assets"; +import { + createFilesystemConnectionEntity, + isFilesystemConfigured, + isFilesystemConnection, +} from "./filesystem"; import { ConnectionEntitySchema } from "./schema"; /** @@ -52,6 +57,16 @@ export const COLLECTION_CONNECTIONS_GET = defineTool({ }; } + // When S3 is configured, check if this is the filesystem connection + if ( + isFilesystemConfigured() && + isFilesystemConnection(input.id, organization.id) + ) { + return { + item: createFilesystemConnectionEntity(organization.id, getBaseUrl()), + }; + } + // Get connection from database const connection = await ctx.storage.connections.findById(input.id); diff --git a/apps/mesh/src/tools/connection/list.ts b/apps/mesh/src/tools/connection/list.ts index 986c48773f..3e6b61a5b1 100644 --- a/apps/mesh/src/tools/connection/list.ts +++ b/apps/mesh/src/tools/connection/list.ts @@ -14,6 +14,7 @@ import { type WhereExpression, } from "@decocms/bindings/collections"; import { LANGUAGE_MODEL_BINDING } from "@decocms/bindings/llm"; +import { FILESYSTEM_BINDING } from "@decocms/bindings/filesystem"; import { OBJECT_STORAGE_BINDING } from "@decocms/bindings/object-storage"; import { WellKnownOrgMCPId } from "@decocms/mesh-sdk"; import { z } from "zod"; @@ -21,12 +22,17 @@ import { defineTool } from "../../core/define-tool"; import { getBaseUrl } from "../../core/server-constants"; import { requireOrganization } from "../../core/mesh-context"; import { createDevAssetsConnectionEntity, isDevMode } from "./dev-assets"; +import { + createFilesystemConnectionEntity, + isFilesystemConfigured, +} from "./filesystem"; import { type ConnectionEntity, ConnectionEntitySchema } from "./schema"; const BUILTIN_BINDING_CHECKERS: Record = { LLM: LANGUAGE_MODEL_BINDING, ASSISTANTS: ASSISTANTS_BINDING, OBJECT_STORAGE: OBJECT_STORAGE_BINDING, + FILESYSTEM: FILESYSTEM_BINDING, }; /** @@ -267,6 +273,20 @@ export const COLLECTION_CONNECTIONS_LIST = defineTool({ } } + // When S3 is configured, inject the filesystem connection + if (isFilesystemConfigured()) { + const baseUrl = getBaseUrl(); + const filesystemId = WellKnownOrgMCPId.FILESYSTEM(organization.id); + + if (!connections.some((c) => c.id === filesystemId)) { + const filesystemConnection = createFilesystemConnectionEntity( + organization.id, + baseUrl, + ); + connections.unshift(filesystemConnection); + } + } + // Filter connections by binding if specified (tools are pre-populated at create/update time) let filteredConnections = bindingChecker ? await Promise.all( diff --git a/apps/mesh/src/tools/filesystem/index.ts b/apps/mesh/src/tools/filesystem/index.ts new file mode 100644 index 0000000000..431f602a44 --- /dev/null +++ b/apps/mesh/src/tools/filesystem/index.ts @@ -0,0 +1,10 @@ +/** + * Filesystem Tools + * + * MCP tools for reading, writing, listing, and deleting files + * in S3-compatible storage. These tools are exposed via the + * filesystem MCP connection, not as core management tools. + */ + +// Re-export schemas +export * from "./schema"; diff --git a/apps/mesh/src/tools/filesystem/schema.ts b/apps/mesh/src/tools/filesystem/schema.ts new file mode 100644 index 0000000000..075422dc3f --- /dev/null +++ b/apps/mesh/src/tools/filesystem/schema.ts @@ -0,0 +1,29 @@ +/** + * Filesystem Schemas + * + * Re-exports schemas from @decocms/bindings for use in MCP tools. + * The bindings package is the source of truth for these schemas. + */ + +export { + FsReadInputSchema, + type FsReadInput, + FsReadOutputSchema, + type FsReadOutput, + FsWriteInputSchema, + type FsWriteInput, + FsWriteOutputSchema, + type FsWriteOutput, + FsListInputSchema, + type FsListInput, + FsListOutputSchema, + type FsListOutput, + FsDeleteInputSchema, + type FsDeleteInput, + FsDeleteOutputSchema, + type FsDeleteOutput, + FsMetadataInputSchema, + type FsMetadataInput, + FsMetadataOutputSchema, + type FsMetadataOutput, +} from "@decocms/bindings"; diff --git a/apps/mesh/src/web/components/details/virtual-mcp/index.tsx b/apps/mesh/src/web/components/details/virtual-mcp/index.tsx index 9c7fe93a10..9705dfa440 100644 --- a/apps/mesh/src/web/components/details/virtual-mcp/index.tsx +++ b/apps/mesh/src/web/components/details/virtual-mcp/index.tsx @@ -21,6 +21,7 @@ import { } from "@deco/ui/components/tooltip.tsx"; import { ORG_ADMIN_PROJECT_SLUG, + WellKnownOrgMCPId, useConnection, useProjectContext, useVirtualMCP, @@ -33,6 +34,7 @@ import { ChevronUp, CubeOutline, File02, + HardDrive, Loading01, Play, Plus, @@ -182,6 +184,69 @@ SkillItem.Fallback = function SkillItemFallback() { ); }; +/** + * Filesystem Access Toggle + * + * Shows a toggle to enable/disable filesystem access for the agent. + * Only visible when the filesystem connection exists (S3 is configured). + * Adding filesystem access creates a connection aggregation with all tools selected. + */ +function FilesystemAccessToggle({ + orgId, + connections, + form, +}: { + orgId: string; + connections: VirtualMCPConnection[]; + form: ReturnType>; +}) { + const filesystemId = WellKnownOrgMCPId.FILESYSTEM(orgId); + const filesystemConnection = useConnection(filesystemId); + + // Don't render if filesystem is not configured (connection doesn't exist) + if (!filesystemConnection) return null; + + const isEnabled = connections.some((c) => c.connection_id === filesystemId); + + const handleToggle = (checked: boolean) => { + if (checked) { + // Add filesystem connection with all tools selected + form.setValue( + "connections", + [ + ...connections, + { + connection_id: filesystemId, + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + { shouldDirty: true }, + ); + } else { + // Remove filesystem connection + form.setValue( + "connections", + connections.filter((c) => c.connection_id !== filesystemId), + { shouldDirty: true }, + ); + } + }; + + return ( +
+
+ +

+ Filesystem Access +

+
+ +
+ ); +} + function VirtualMcpDetailViewWithData({ virtualMcp, }: { @@ -494,6 +559,15 @@ function VirtualMcpDetailViewWithData({ + {/* Filesystem Access section */} + + + + {/* Instructions section */}

diff --git a/packages/bindings/package.json b/packages/bindings/package.json index aa9d8621e6..dabb66eff1 100644 --- a/packages/bindings/package.json +++ b/packages/bindings/package.json @@ -22,6 +22,7 @@ "./collections": "./src/well-known/collections.ts", "./llm": "./src/well-known/language-model.ts", "./object-storage": "./src/well-known/object-storage.ts", + "./filesystem": "./src/well-known/filesystem.ts", "./connection": "./src/core/connection.ts", "./client": "./src/core/client/index.ts", "./mcp": "./src/well-known/mcp.ts", diff --git a/packages/bindings/src/index.ts b/packages/bindings/src/index.ts index 4949c0b7c1..79e2efddde 100644 --- a/packages/bindings/src/index.ts +++ b/packages/bindings/src/index.ts @@ -121,6 +121,32 @@ export { type DeleteObjectsOutput, } from "./well-known/object-storage"; +// Re-export filesystem binding types +export { + FILESYSTEM_BINDING, + type FilesystemBinding, + type FsReadInput, + type FsReadOutput, + FsReadInputSchema, + FsReadOutputSchema, + type FsWriteInput, + type FsWriteOutput, + FsWriteInputSchema, + FsWriteOutputSchema, + type FsListInput, + type FsListOutput, + FsListInputSchema, + FsListOutputSchema, + type FsDeleteInput, + type FsDeleteOutput, + FsDeleteInputSchema, + FsDeleteOutputSchema, + type FsMetadataInput, + type FsMetadataOutput, + FsMetadataInputSchema, + FsMetadataOutputSchema, +} from "./well-known/filesystem"; + // Re-export workflow binding types export { WORKFLOWS_COLLECTION_BINDING } from "./well-known/workflow"; diff --git a/packages/bindings/src/well-known/filesystem.ts b/packages/bindings/src/well-known/filesystem.ts new file mode 100644 index 0000000000..0093f81c40 --- /dev/null +++ b/packages/bindings/src/well-known/filesystem.ts @@ -0,0 +1,234 @@ +/** + * Filesystem Well-Known Binding + * + * Defines the interface for agent-oriented filesystem operations backed by S3-compatible storage. + * Unlike OBJECT_STORAGE_BINDING (which uses presigned URLs for browser/UI use), + * this binding provides inline content access designed for AI agents. + * + * This binding includes: + * - FS_READ: Read file content inline (text or base64 for binary) + * - FS_WRITE: Write file content inline + * - FS_LIST: List files and directories with pattern filtering + * - FS_DELETE: Delete a single file + * - FS_METADATA: Get file metadata (size, content type, etc.) + */ + +import { z } from "zod"; +import type { Binder, ToolBinder } from "../core/binder"; + +// ============================================================================ +// Tool Schemas +// ============================================================================ + +/** + * FS_READ - Read file content inline + * + * Returns content directly for text files and small binary files (as base64). + * For files exceeding the size limit, returns an error with file metadata. + */ +const FsReadInputSchema = z.object({ + path: z.string().describe("File path to read (e.g., 'docs/readme.md')"), + offset: z + .number() + .optional() + .describe( + "Byte offset to start reading from (for partial reads of large files)", + ), + limit: z + .number() + .optional() + .describe("Maximum number of bytes to read (for partial reads)"), +}); + +const FsReadOutputSchema = z.object({ + content: z + .string() + .optional() + .describe( + "File content as text (for text files) or base64 (for small binary files)", + ), + encoding: z + .enum(["utf-8", "base64"]) + .optional() + .describe("Content encoding: utf-8 for text, base64 for binary"), + contentType: z.string().optional().describe("MIME type of the file"), + size: z.number().describe("Total file size in bytes"), + error: z + .enum(["FILE_NOT_FOUND", "FILE_TOO_LARGE"]) + .optional() + .describe("Error code if file cannot be read inline"), +}); + +export type FsReadInput = z.infer; +export type FsReadOutput = z.infer; + +export { FsReadInputSchema, FsReadOutputSchema }; + +/** + * FS_WRITE - Write file content inline + * + * Creates or overwrites a file with the provided content. + */ +const FsWriteInputSchema = z.object({ + path: z.string().describe("File path to write to (e.g., 'docs/readme.md')"), + content: z.string().describe("File content to write"), + encoding: z + .enum(["utf-8", "base64"]) + .optional() + .default("utf-8") + .describe("Content encoding: utf-8 for text (default), base64 for binary"), + contentType: z + .string() + .optional() + .describe( + "MIME type for the file (auto-detected from extension if omitted)", + ), +}); + +const FsWriteOutputSchema = z.object({ + path: z.string().describe("Path of the written file"), + size: z.number().describe("Size of the written file in bytes"), +}); + +export type FsWriteInput = z.infer; +export type FsWriteOutput = z.infer; + +export { FsWriteInputSchema, FsWriteOutputSchema }; + +/** + * FS_LIST - List files and directories + * + * Lists entries at a given path with optional pattern filtering. + * Uses S3 prefix/delimiter semantics to simulate directory listing. + */ +const FsListInputSchema = z.object({ + path: z + .string() + .optional() + .describe("Directory path to list (e.g., 'docs/'). Defaults to root."), + pattern: z + .string() + .optional() + .describe( + "Glob pattern to filter results by key name (e.g., '*.md'). Applied to key names only, not content.", + ), + continuationToken: z + .string() + .optional() + .describe("Token for pagination from previous response"), + maxKeys: z + .number() + .optional() + .default(100) + .describe("Maximum number of entries to return (default: 100, max: 1000)"), +}); + +const FsListOutputSchema = z.object({ + entries: z + .array( + z.object({ + path: z.string().describe("File or directory path"), + type: z.enum(["file", "directory"]).describe("Entry type"), + size: z.number().optional().describe("File size in bytes (files only)"), + lastModified: z + .string() + .optional() + .describe("Last modified timestamp (files only)"), + }), + ) + .describe("List of file and directory entries"), + nextContinuationToken: z + .string() + .optional() + .describe("Token for fetching next page of results"), + isTruncated: z.boolean().describe("Whether there are more results available"), +}); + +export type FsListInput = z.infer; +export type FsListOutput = z.infer; + +export { FsListInputSchema, FsListOutputSchema }; + +/** + * FS_DELETE - Delete a single file + */ +const FsDeleteInputSchema = z.object({ + path: z.string().describe("File path to delete"), +}); + +const FsDeleteOutputSchema = z.object({ + success: z.boolean().describe("Whether the deletion was successful"), + path: z.string().describe("Path of the deleted file"), +}); + +export type FsDeleteInput = z.infer; +export type FsDeleteOutput = z.infer; + +export { FsDeleteInputSchema, FsDeleteOutputSchema }; + +/** + * FS_METADATA - Get file metadata + */ +const FsMetadataInputSchema = z.object({ + path: z.string().describe("File path to get metadata for"), +}); + +const FsMetadataOutputSchema = z.object({ + size: z.number().describe("File size in bytes"), + contentType: z.string().optional().describe("MIME type of the file"), + lastModified: z.string().describe("Last modified timestamp"), + etag: z.string().describe("Entity tag for the file"), +}); + +export type FsMetadataInput = z.infer; +export type FsMetadataOutput = z.infer; + +export { FsMetadataInputSchema, FsMetadataOutputSchema }; + +// ============================================================================ +// Binding Definition +// ============================================================================ + +/** + * Filesystem Binding + * + * Agent-oriented filesystem interface backed by S3-compatible storage. + * Provides inline content access (read/write file content directly in tool calls) + * as opposed to OBJECT_STORAGE_BINDING which uses presigned URLs. + * + * Required tools: + * - FS_READ: Read file content inline + * - FS_WRITE: Write file content inline + * - FS_LIST: List files and directories + * - FS_DELETE: Delete a file + * - FS_METADATA: Get file metadata + */ +export const FILESYSTEM_BINDING = [ + { + name: "FS_READ" as const, + inputSchema: FsReadInputSchema, + outputSchema: FsReadOutputSchema, + } satisfies ToolBinder<"FS_READ", FsReadInput, FsReadOutput>, + { + name: "FS_WRITE" as const, + inputSchema: FsWriteInputSchema, + outputSchema: FsWriteOutputSchema, + } satisfies ToolBinder<"FS_WRITE", FsWriteInput, FsWriteOutput>, + { + name: "FS_LIST" as const, + inputSchema: FsListInputSchema, + outputSchema: FsListOutputSchema, + } satisfies ToolBinder<"FS_LIST", FsListInput, FsListOutput>, + { + name: "FS_DELETE" as const, + inputSchema: FsDeleteInputSchema, + outputSchema: FsDeleteOutputSchema, + } satisfies ToolBinder<"FS_DELETE", FsDeleteInput, FsDeleteOutput>, + { + name: "FS_METADATA" as const, + inputSchema: FsMetadataInputSchema, + outputSchema: FsMetadataOutputSchema, + } satisfies ToolBinder<"FS_METADATA", FsMetadataInput, FsMetadataOutput>, +] as const satisfies Binder; + +export type FilesystemBinding = typeof FILESYSTEM_BINDING; diff --git a/packages/mesh-sdk/src/index.ts b/packages/mesh-sdk/src/index.ts index dd8571efdf..c38f662a5a 100644 --- a/packages/mesh-sdk/src/index.ts +++ b/packages/mesh-sdk/src/index.ts @@ -195,6 +195,8 @@ export { getWellKnownCommunityRegistryConnection, getWellKnownSelfConnection, getWellKnownDevAssetsConnection, + getWellKnownFilesystemConnection, + FILESYSTEM_MCP_ALIAS_ID, getWellKnownOpenRouterConnection, getWellKnownMcpStudioConnection, // Virtual MCP factory functions diff --git a/packages/mesh-sdk/src/lib/constants.ts b/packages/mesh-sdk/src/lib/constants.ts index 0a66cfb0fb..22969bd30b 100644 --- a/packages/mesh-sdk/src/lib/constants.ts +++ b/packages/mesh-sdk/src/lib/constants.ts @@ -26,6 +26,8 @@ export const WellKnownOrgMCPId = { COMMUNITY_REGISTRY: (org: string) => `${org}_community-registry`, /** Dev Assets MCP - local file storage for development */ DEV_ASSETS: (org: string) => `${org}_dev-assets`, + /** Filesystem MCP - S3-backed filesystem for agents */ + FILESYSTEM: (org: string) => `${org}_filesystem`, }; /** @@ -35,6 +37,13 @@ export const WellKnownOrgMCPId = { */ export const SELF_MCP_ALIAS_ID = "self"; +/** + * Frontend connection ID for the filesystem MCP endpoint. + * Use this constant when calling filesystem tools from the frontend. + * The endpoint is exposed at /mcp/filesystem. + */ +export const FILESYSTEM_MCP_ALIAS_ID = "filesystem"; + /** * Frontend connection ID for the dev-assets MCP endpoint. * Use this constant when calling object storage tools from the frontend in dev mode. @@ -169,6 +178,41 @@ export function getWellKnownDevAssetsConnection( }; } +/** + * Get well-known connection definition for the Filesystem MCP. + * S3-backed filesystem for AI agents with inline content access. + * Implements FILESYSTEM_BINDING. + * + * @param baseUrl - The base URL for the MCP server + * @param orgId - The organization ID + * @returns ConnectionCreateData for the Filesystem MCP + */ +export function getWellKnownFilesystemConnection( + baseUrl: string, + orgId: string, +): ConnectionCreateData { + return { + id: WellKnownOrgMCPId.FILESYSTEM(orgId), + title: "Filesystem", + description: + "S3-backed filesystem for AI agents. Read and write files directly.", + connection_type: "HTTP", + connection_url: `${baseUrl}/mcp/${FILESYSTEM_MCP_ALIAS_ID}`, + icon: "https://api.iconify.design/lucide:hard-drive.svg?color=%23888", + app_name: "@deco/filesystem-mcp", + app_id: null, + connection_token: null, + connection_headers: null, + oauth_config: null, + configuration_state: null, + configuration_scopes: null, + metadata: { + isFixed: true, + type: "filesystem", + }, + }; +} + /** * Get well-known connection definition for OpenRouter. * Used by the chat UI to offer a one-click install when no model provider is connected. From 5af64647192ec65f555a933d8c53828110eea5a9 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 17 Mar 2026 16:29:38 -0300 Subject: [PATCH 2/2] fix(filesystem): remove unused barrel export flagged by knip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The index.ts was never imported — filesystem-mcp.ts imports directly from schema.ts. Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/tools/filesystem/index.ts | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 apps/mesh/src/tools/filesystem/index.ts diff --git a/apps/mesh/src/tools/filesystem/index.ts b/apps/mesh/src/tools/filesystem/index.ts deleted file mode 100644 index 431f602a44..0000000000 --- a/apps/mesh/src/tools/filesystem/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Filesystem Tools - * - * MCP tools for reading, writing, listing, and deleting files - * in S3-compatible storage. These tools are exposed via the - * filesystem MCP connection, not as core management tools. - */ - -// Re-export schemas -export * from "./schema";