@papercraneai/sandbox-agent 0.1.13 → 0.1.14-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,190 @@
1
+ import { open } from "fs/promises";
2
+ import { extname } from "path";
3
+ import { lookup } from "./file-index.js";
4
+ const DEFAULT_LIMIT = 2000;
5
+ const MAX_LINE_LENGTH = 2000;
6
+ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
7
+ const MAX_BYTES = 50 * 1024;
8
+ const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`;
9
+ const SAMPLE_BYTES = 4096;
10
+ // Binary-file detection ported from opencode read.ts:104-149 (MIT).
11
+ // Pure function: extension list + null-byte / non-printable density check.
12
+ const BINARY_EXTENSIONS = new Set([
13
+ ".zip", ".tar", ".gz", ".exe", ".dll", ".so", ".class", ".jar", ".war",
14
+ ".7z", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods",
15
+ ".odp", ".bin", ".dat", ".obj", ".o", ".a", ".lib", ".wasm", ".pyc", ".pyo",
16
+ // Images and PDFs are also binary; we report them as binary for v1 since the
17
+ // shared-view chat surface doesn't have an image-attachment path. We can add
18
+ // base64 attachment support later if useful.
19
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".ico", ".svg"
20
+ ]);
21
+ function isBinaryFile(filepath, sample) {
22
+ if (BINARY_EXTENSIONS.has(extname(filepath).toLowerCase()))
23
+ return true;
24
+ if (sample.length === 0)
25
+ return false;
26
+ let nonPrintableCount = 0;
27
+ for (let i = 0; i < sample.length; i++) {
28
+ if (sample[i] === 0)
29
+ return true;
30
+ if (sample[i] < 9 || (sample[i] > 13 && sample[i] < 32)) {
31
+ nonPrintableCount++;
32
+ }
33
+ }
34
+ return nonPrintableCount / sample.length > 0.3;
35
+ }
36
+ async function readSample(filePath, fileSize) {
37
+ const handle = await open(filePath, "r");
38
+ try {
39
+ const sampleSize = Math.min(fileSize, SAMPLE_BYTES);
40
+ const buf = Buffer.alloc(sampleSize);
41
+ await handle.read(buf, 0, sampleSize, 0);
42
+ return buf;
43
+ }
44
+ finally {
45
+ await handle.close();
46
+ }
47
+ }
48
+ async function readLines(filePath, offset, limit) {
49
+ const { createReadStream } = await import("fs");
50
+ const { createInterface } = await import("readline");
51
+ const stream = createReadStream(filePath, { encoding: "utf-8" });
52
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
53
+ const lines = [];
54
+ let totalLines = 0;
55
+ let bytes = 0;
56
+ let truncatedByBytes = false;
57
+ let hasMore = false;
58
+ try {
59
+ for await (const rawLine of rl) {
60
+ totalLines++;
61
+ if (totalLines < offset)
62
+ continue;
63
+ if (lines.length >= limit) {
64
+ hasMore = true;
65
+ // Continue counting totalLines so the caller can report the full count.
66
+ continue;
67
+ }
68
+ let line = rawLine;
69
+ if (line.length > MAX_LINE_LENGTH) {
70
+ line = line.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX;
71
+ }
72
+ const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
73
+ if (bytes + lineBytes > MAX_BYTES) {
74
+ truncatedByBytes = true;
75
+ hasMore = true;
76
+ // Continue counting totalLines so the caller can report the full count.
77
+ continue;
78
+ }
79
+ bytes += lineBytes;
80
+ lines.push(line);
81
+ }
82
+ }
83
+ finally {
84
+ rl.close();
85
+ stream.destroy();
86
+ }
87
+ const startLine = offset;
88
+ const endLine = startLine + lines.length - 1;
89
+ return {
90
+ content: lines.map((l, i) => `${i + startLine}: ${l}`).join("\n"),
91
+ totalLines,
92
+ startLine,
93
+ endLine,
94
+ truncatedByBytes,
95
+ hasMore
96
+ };
97
+ }
98
+ /**
99
+ * Reads a file by handle. The agent never sees or constructs a path; the
100
+ * `fileId` is opaque and resolves to an absolute path only via the
101
+ * server-built index. Output format mirrors what models trained on the
102
+ * SDK Read tool already expect: `<line>: <content>` with truncation
103
+ * markers and a footer explaining offset/total.
104
+ */
105
+ export async function executeReadFile(index, args) {
106
+ const entry = lookup(index, args.fileId);
107
+ if (!entry) {
108
+ return {
109
+ content: [{ type: "text", text: `Error: unknown fileId. Use ListFiles to discover available files.` }],
110
+ isError: true
111
+ };
112
+ }
113
+ const offsetRaw = args.offset;
114
+ const limitRaw = args.limit;
115
+ const offset = typeof offsetRaw === "number" && offsetRaw >= 1 ? Math.floor(offsetRaw) : 1;
116
+ const limit = typeof limitRaw === "number" && limitRaw >= 1 ? Math.floor(limitRaw) : DEFAULT_LIMIT;
117
+ if (offset < 1) {
118
+ return {
119
+ content: [{ type: "text", text: `Error: offset must be >= 1` }],
120
+ isError: true
121
+ };
122
+ }
123
+ let sample;
124
+ try {
125
+ sample = await readSample(entry.absolutePath, entry.size);
126
+ }
127
+ catch (err) {
128
+ return {
129
+ content: [{ type: "text", text: `Error reading file: ${err instanceof Error ? err.message : String(err)}` }],
130
+ isError: true
131
+ };
132
+ }
133
+ if (isBinaryFile(entry.absolutePath, sample)) {
134
+ return {
135
+ content: [{ type: "text", text: `Error: cannot read binary file ${entry.relativePath}` }],
136
+ isError: true
137
+ };
138
+ }
139
+ const result = await readLines(entry.absolutePath, offset, limit);
140
+ if (result.totalLines === 0 && offset === 1) {
141
+ return {
142
+ content: [{ type: "text", text: `<path>${entry.relativePath}</path>\n<content>\n(empty file)\n</content>` }]
143
+ };
144
+ }
145
+ if (result.totalLines < offset) {
146
+ return {
147
+ content: [{ type: "text", text: `Error: offset ${offset} is out of range for this file (${result.totalLines} lines)` }],
148
+ isError: true
149
+ };
150
+ }
151
+ let footer;
152
+ if (result.truncatedByBytes) {
153
+ footer = `(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${result.startLine}-${result.endLine}. Use offset=${result.endLine + 1} to continue.)`;
154
+ }
155
+ else if (result.hasMore) {
156
+ footer = `(Showing lines ${result.startLine}-${result.endLine} of ${result.totalLines}. Use offset=${result.endLine + 1} to continue.)`;
157
+ }
158
+ else {
159
+ footer = `(End of file - total ${result.totalLines} lines)`;
160
+ }
161
+ const text = [
162
+ `<path>${entry.relativePath}</path>`,
163
+ `<content>`,
164
+ result.content,
165
+ ``,
166
+ footer,
167
+ `</content>`
168
+ ].join("\n");
169
+ return { content: [{ type: "text", text }] };
170
+ }
171
+ /**
172
+ * Returns the index as a list of `{ fileId, relativePath, size, writable }`.
173
+ * The agent uses this to discover what's readable before issuing ReadFile.
174
+ */
175
+ export function executeListFiles(index) {
176
+ const items = [...index.entries()].map(([fileId, entry]) => ({
177
+ fileId,
178
+ relativePath: entry.relativePath,
179
+ size: entry.size,
180
+ writable: entry.writable
181
+ }));
182
+ // Sort by relativePath for stable, predictable output.
183
+ items.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
184
+ return {
185
+ content: [{
186
+ type: "text",
187
+ text: JSON.stringify({ files: items, total: items.length }, null, 2)
188
+ }]
189
+ };
190
+ }
@@ -0,0 +1,36 @@
1
+ import type { FileIndex } from "./file-index.js";
2
+ interface WriteFileArgs {
3
+ fileId: unknown;
4
+ content: unknown;
5
+ }
6
+ interface ToolResult {
7
+ content: {
8
+ type: "text";
9
+ text: string;
10
+ }[];
11
+ isError?: boolean;
12
+ [key: string]: unknown;
13
+ }
14
+ /**
15
+ * Writes a file by handle. The fileId must reference a writable entry in the index.
16
+ * Preserves the original file's line endings so diffs stay clean. Returns a unified
17
+ * diff of what changed.
18
+ *
19
+ * Not in shared-view's `allowedTools` for v1 — registered for use by future authoring
20
+ * surfaces with reduced privilege.
21
+ */
22
+ export declare function executeWriteFile(index: FileIndex, args: WriteFileArgs): Promise<ToolResult>;
23
+ interface CreateFileArgs {
24
+ relativePath: unknown;
25
+ content: unknown;
26
+ }
27
+ /**
28
+ * Creates a new file under the dashboard root. Validates the relativePath has
29
+ * no `..` segments and isn't absolute; the absolute path is resolved against the
30
+ * dashboard root (passed in via `dashboardRoot`). The new file is added to the
31
+ * index and a fresh fileId is returned for subsequent reads/writes.
32
+ *
33
+ * Not in shared-view's `allowedTools` for v1.
34
+ */
35
+ export declare function executeCreateFile(index: FileIndex, dashboardRoot: string, args: CreateFileArgs): Promise<ToolResult>;
36
+ export {};
@@ -0,0 +1,179 @@
1
+ import { readFile, writeFile, stat } from "fs/promises";
2
+ import { createTwoFilesPatch } from "diff";
3
+ import { lookup } from "./file-index.js";
4
+ /**
5
+ * Detects whether the given text uses CRLF line endings. Ported from gemini-cli's
6
+ * write-file.ts approach (Apache 2.0). When the existing file uses CRLF, new content
7
+ * is normalized to CRLF too — avoids polluting diffs with line-ending churn.
8
+ */
9
+ function detectCrlf(text) {
10
+ return text.includes("\r\n");
11
+ }
12
+ function applyLineEndings(text, useCrlf) {
13
+ // Normalize the input first to LF, then upgrade if needed.
14
+ const lf = text.replaceAll("\r\n", "\n");
15
+ return useCrlf ? lf.replaceAll("\n", "\r\n") : lf;
16
+ }
17
+ async function readExisting(entry) {
18
+ try {
19
+ const buf = await readFile(entry.absolutePath, "utf-8");
20
+ return { existed: true, content: buf };
21
+ }
22
+ catch (err) {
23
+ if (err.code === "ENOENT") {
24
+ return { existed: false, content: "" };
25
+ }
26
+ throw err;
27
+ }
28
+ }
29
+ /**
30
+ * Writes a file by handle. The fileId must reference a writable entry in the index.
31
+ * Preserves the original file's line endings so diffs stay clean. Returns a unified
32
+ * diff of what changed.
33
+ *
34
+ * Not in shared-view's `allowedTools` for v1 — registered for use by future authoring
35
+ * surfaces with reduced privilege.
36
+ */
37
+ export async function executeWriteFile(index, args) {
38
+ const entry = lookup(index, args.fileId);
39
+ if (!entry) {
40
+ return {
41
+ content: [{ type: "text", text: `Error: unknown fileId. Use ListFiles to discover available files.` }],
42
+ isError: true
43
+ };
44
+ }
45
+ if (!entry.writable) {
46
+ return {
47
+ content: [{ type: "text", text: `Error: file ${entry.relativePath} is not writable in this session.` }],
48
+ isError: true
49
+ };
50
+ }
51
+ if (typeof args.content !== "string") {
52
+ return {
53
+ content: [{ type: "text", text: `Error: content must be a string` }],
54
+ isError: true
55
+ };
56
+ }
57
+ let existing;
58
+ try {
59
+ existing = await readExisting(entry);
60
+ }
61
+ catch (err) {
62
+ return {
63
+ content: [{ type: "text", text: `Error reading existing file: ${err instanceof Error ? err.message : String(err)}` }],
64
+ isError: true
65
+ };
66
+ }
67
+ const useCrlf = existing.existed && detectCrlf(existing.content);
68
+ const newContent = applyLineEndings(args.content, useCrlf);
69
+ try {
70
+ await writeFile(entry.absolutePath, newContent, "utf-8");
71
+ }
72
+ catch (err) {
73
+ return {
74
+ content: [{ type: "text", text: `Error writing file: ${err instanceof Error ? err.message : String(err)}` }],
75
+ isError: true
76
+ };
77
+ }
78
+ // Refresh the index entry's size so subsequent ReadFile reflects the new state.
79
+ try {
80
+ const s = await stat(entry.absolutePath);
81
+ entry.size = s.size;
82
+ }
83
+ catch { /* non-fatal */ }
84
+ const diff = createTwoFilesPatch(entry.relativePath, entry.relativePath, existing.content, newContent, existing.existed ? "before" : "(new file)", "after");
85
+ // Strip the standard `Index:` header that diff emits — it's noise for the model.
86
+ const trimmedDiff = diff.replace(/^Index: .*\n=+\n/m, "");
87
+ const summary = existing.existed
88
+ ? `Wrote ${entry.relativePath} (${newContent.length} bytes)`
89
+ : `Created ${entry.relativePath} (${newContent.length} bytes)`;
90
+ return {
91
+ content: [{
92
+ type: "text",
93
+ text: `${summary}\n\n${trimmedDiff}`
94
+ }]
95
+ };
96
+ }
97
+ /**
98
+ * Creates a new file under the dashboard root. Validates the relativePath has
99
+ * no `..` segments and isn't absolute; the absolute path is resolved against the
100
+ * dashboard root (passed in via `dashboardRoot`). The new file is added to the
101
+ * index and a fresh fileId is returned for subsequent reads/writes.
102
+ *
103
+ * Not in shared-view's `allowedTools` for v1.
104
+ */
105
+ export async function executeCreateFile(index, dashboardRoot, args) {
106
+ if (typeof args.relativePath !== "string" || args.relativePath.length === 0) {
107
+ return {
108
+ content: [{ type: "text", text: `Error: relativePath must be a non-empty string` }],
109
+ isError: true
110
+ };
111
+ }
112
+ if (typeof args.content !== "string") {
113
+ return {
114
+ content: [{ type: "text", text: `Error: content must be a string` }],
115
+ isError: true
116
+ };
117
+ }
118
+ const rel = args.relativePath;
119
+ if (rel.startsWith("/") || rel.includes("\0")) {
120
+ return {
121
+ content: [{ type: "text", text: `Error: relativePath must be relative and not contain null bytes` }],
122
+ isError: true
123
+ };
124
+ }
125
+ // Reject any path segment equal to ".." — can't escape the root.
126
+ const segments = rel.split(/[\\/]/);
127
+ if (segments.some((s) => s === ".." || s === ".")) {
128
+ return {
129
+ content: [{ type: "text", text: `Error: relativePath must not contain "." or ".." segments` }],
130
+ isError: true
131
+ };
132
+ }
133
+ const { join, isAbsolute } = await import("path");
134
+ const { randomBytes } = await import("crypto");
135
+ const absolutePath = join(dashboardRoot, rel);
136
+ // Belt and suspenders — the resolved absolute path must be under dashboardRoot.
137
+ if (!isAbsolute(absolutePath) || !absolutePath.startsWith(dashboardRoot)) {
138
+ return {
139
+ content: [{ type: "text", text: `Error: resolved path is outside dashboard root` }],
140
+ isError: true
141
+ };
142
+ }
143
+ // Don't overwrite an existing file silently — Write does that, Create doesn't.
144
+ try {
145
+ await stat(absolutePath);
146
+ return {
147
+ content: [{ type: "text", text: `Error: file already exists at ${rel}. Use WriteFile to overwrite.` }],
148
+ isError: true
149
+ };
150
+ }
151
+ catch { /* expected — file shouldn't exist */ }
152
+ // Write the file, then add to index.
153
+ const { mkdir } = await import("fs/promises");
154
+ const { dirname } = await import("path");
155
+ try {
156
+ await mkdir(dirname(absolutePath), { recursive: true });
157
+ await writeFile(absolutePath, args.content, "utf-8");
158
+ }
159
+ catch (err) {
160
+ return {
161
+ content: [{ type: "text", text: `Error creating file: ${err instanceof Error ? err.message : String(err)}` }],
162
+ isError: true
163
+ };
164
+ }
165
+ const fileId = `f_${randomBytes(8).toString("hex")}`;
166
+ const s = await stat(absolutePath);
167
+ index.set(fileId, {
168
+ absolutePath,
169
+ relativePath: rel,
170
+ writable: true,
171
+ size: s.size
172
+ });
173
+ return {
174
+ content: [{
175
+ type: "text",
176
+ text: `Created ${rel} (${s.size} bytes). fileId: ${fileId}`
177
+ }]
178
+ };
179
+ }
package/dist/index.js CHANGED
@@ -4,6 +4,8 @@ import { createServer } from "http";
4
4
  import { WebSocketServer, WebSocket } from "ws";
5
5
  import { Tail } from "tail";
6
6
  import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
7
+ import { createDashboardFsServer } from "./dashboard-fs/index.js";
8
+ import { createPapercraneDataServer } from "./papercrane-data/index.js";
7
9
  import { realpathSync } from "fs";
8
10
  import { readdir, stat, mkdir, readFile, writeFile, access, unlink, rm } from "fs/promises";
9
11
  import { join, dirname, resolve } from "path";
@@ -1136,7 +1138,21 @@ app.post("/chat", async (req, res) => {
1136
1138
  const requestId = Math.random().toString(36).substring(7);
1137
1139
  const { message, sessionId, systemPrompt, verbose = false, subdir, selectedDashboard,
1138
1140
  // Configurable agent options
1139
- maxTurns = 40, allowedTools, disallowedTools, model, maxBudgetUsd } = req.body;
1141
+ maxTurns = 40, allowedTools, disallowedTools, model, maxBudgetUsd,
1142
+ // Reasoning effort (low | medium | high | xhigh | max | <number>). The
1143
+ // SDK defaults to "high" on Opus 4.6+; "medium" is Anthropic's recommended
1144
+ // balance of speed/cost/performance for most agentic workflows. Callers can
1145
+ // override per request when a heavier or lighter pass is warranted.
1146
+ effort = "medium",
1147
+ // Shared-view chat fields (Phase 2a/2b). When mode === "shared-view" the agent
1148
+ // gets a locked-down tool set (handle-based file tools + scoped integration
1149
+ // calls only; no Bash/Web/SDK file tools) regardless of the caller's
1150
+ // `allowedTools` value. The dashboard folder is identified via the existing
1151
+ // `subdir` field (relative to PROJECT_DIR); Papercrane doesn't need to know
1152
+ // the sandbox's absolute filesystem layout. `queryFiles` is an optional list
1153
+ // of additional relative paths to include in the file index. The remaining
1154
+ // fields configure the papercrane-data MCP server (Phase 2b).
1155
+ mode, queryFiles, shareId, shareCode, visitorSessionId } = req.body;
1140
1156
  const ctx = { requestId, sessionId };
1141
1157
  log.info(ctx, "Agent received chat request");
1142
1158
  if (!message) {
@@ -1185,7 +1201,7 @@ app.post("/chat", async (req, res) => {
1185
1201
  parent_tool_use_id: null
1186
1202
  };
1187
1203
  }
1188
- // Default tools if not specified
1204
+ // Default tools if not specified (authoring mode)
1189
1205
  const defaultTools = [
1190
1206
  "Read",
1191
1207
  "Write",
@@ -1199,18 +1215,111 @@ app.post("/chat", async (req, res) => {
1199
1215
  "mcp__client-tools__ShowPreview",
1200
1216
  "mcp__client-tools__GetContext"
1201
1217
  ];
1218
+ // Locked-down tool set for shared-view chat. Server-enforced regardless of
1219
+ // any `allowedTools` passed in the request body — defense-in-depth so a
1220
+ // miswiring on the Papercrane side can't widen the agent's surface.
1221
+ const sharedViewAllowedTools = [
1222
+ "mcp__dashboard-fs__ListFiles",
1223
+ "mcp__dashboard-fs__ReadFile",
1224
+ "mcp__dashboard-fs__GrepFiles",
1225
+ "mcp__papercrane-data__List",
1226
+ "mcp__papercrane-data__Describe",
1227
+ "mcp__papercrane-data__Call",
1228
+ "mcp__client-tools__GetContext"
1229
+ ];
1230
+ const isSharedView = mode === "shared-view";
1202
1231
  // Store UI context for GetContext tool
1203
1232
  sessionContext[requestId] = { selectedDashboard: selectedDashboard || null };
1204
1233
  const clientTools = createClientToolsServer(requestId);
1234
+ // Build the dashboard-fs MCP server for shared-view chat. The file index is
1235
+ // walked once at session start from `cwd` (PROJECT_DIR + subdir); the agent
1236
+ // addresses files only by handle.
1237
+ let dashboardFs = null;
1238
+ let papercraneData = null;
1239
+ if (isSharedView) {
1240
+ if (!subdir || typeof subdir !== "string") {
1241
+ res.status(400).json({ error: "subdir (the dashboard folder name) is required when mode is 'shared-view'" });
1242
+ return;
1243
+ }
1244
+ if (typeof shareId !== "number") {
1245
+ res.status(400).json({ error: "shareId is required when mode is 'shared-view'" });
1246
+ return;
1247
+ }
1248
+ if (!shareCode || typeof shareCode !== "string") {
1249
+ res.status(400).json({ error: "shareCode is required when mode is 'shared-view'" });
1250
+ return;
1251
+ }
1252
+ if (!visitorSessionId || typeof visitorSessionId !== "string") {
1253
+ res.status(400).json({ error: "visitorSessionId is required when mode is 'shared-view'" });
1254
+ return;
1255
+ }
1256
+ // Read Papercrane credentials from the same config file the CLI uses
1257
+ // (~/.papercrane/config.json). Single source of truth.
1258
+ let papercraneApiUrl;
1259
+ let papercraneApiKey;
1260
+ try {
1261
+ const configPath = join(homedir(), ".papercrane", "config.json");
1262
+ const raw = await readFile(configPath, "utf-8");
1263
+ const cfg = JSON.parse(raw);
1264
+ console.log(`[shared-view] full config:`, cfg);
1265
+ if (!cfg.apiKey)
1266
+ throw new Error("apiKey missing from ~/.papercrane/config.json");
1267
+ if (!cfg.apiBaseUrl)
1268
+ throw new Error("apiBaseUrl missing from ~/.papercrane/config.json");
1269
+ papercraneApiKey = cfg.apiKey;
1270
+ papercraneApiUrl = cfg.apiBaseUrl;
1271
+ }
1272
+ catch (err) {
1273
+ log.error({ ...ctx, err: err instanceof Error ? err.message : String(err) }, "Failed to load Papercrane credentials");
1274
+ res.status(500).json({ error: "Sandbox is missing Papercrane credentials. Run `papercrane login` to set up the API key." });
1275
+ return;
1276
+ }
1277
+ try {
1278
+ // Resolve queryFiles to absolute paths if relative (callers pass relative paths)
1279
+ const resolvedQueryFiles = Array.isArray(queryFiles)
1280
+ ? queryFiles.map((qf) => qf.startsWith("/") ? qf : join(PROJECT_DIR, qf))
1281
+ : undefined;
1282
+ dashboardFs = await createDashboardFsServer({
1283
+ dashboardRoot: cwd,
1284
+ queryFiles: resolvedQueryFiles
1285
+ });
1286
+ }
1287
+ catch (err) {
1288
+ log.error({ ...ctx, err: err instanceof Error ? err.message : String(err) }, "Failed to build dashboard-fs index");
1289
+ res.status(400).json({ error: `Failed to build dashboard-fs index: ${err instanceof Error ? err.message : String(err)}` });
1290
+ return;
1291
+ }
1292
+ papercraneData = createPapercraneDataServer({
1293
+ apiBaseUrl: papercraneApiUrl,
1294
+ apiKey: papercraneApiKey,
1295
+ shareCode,
1296
+ visitorSessionId
1297
+ });
1298
+ log.info({
1299
+ ...ctx,
1300
+ shareId,
1301
+ shareCode,
1302
+ visitorSessionId,
1303
+ cwd,
1304
+ papercraneApiUrl
1305
+ }, "Shared-view chat session initialized");
1306
+ }
1307
+ const mcpServers = {
1308
+ "client-tools": clientTools
1309
+ };
1310
+ if (dashboardFs) {
1311
+ mcpServers["dashboard-fs"] = dashboardFs.server;
1312
+ }
1313
+ if (papercraneData) {
1314
+ mcpServers["papercrane-data"] = papercraneData.server;
1315
+ }
1205
1316
  const options = {
1206
1317
  maxTurns,
1207
1318
  cwd,
1208
1319
  permissionMode: "bypassPermissions",
1209
1320
  allowDangerouslySkipPermissions: true,
1210
- mcpServers: {
1211
- "client-tools": clientTools
1212
- },
1213
- allowedTools: allowedTools || defaultTools,
1321
+ mcpServers,
1322
+ allowedTools: isSharedView ? sharedViewAllowedTools : (allowedTools || defaultTools),
1214
1323
  settingSources: ["project"],
1215
1324
  hooks,
1216
1325
  abortController,
@@ -1234,6 +1343,9 @@ app.post("/chat", async (req, res) => {
1234
1343
  if (maxBudgetUsd) {
1235
1344
  options.maxBudgetUsd = maxBudgetUsd;
1236
1345
  }
1346
+ if (effort !== undefined) {
1347
+ options.effort = effort;
1348
+ }
1237
1349
  try {
1238
1350
  let gotResult = false;
1239
1351
  log.debug({ ...ctx, elapsed: Date.now() - requestStartTime }, "Starting Claude SDK query");
@@ -1310,9 +1422,15 @@ app.post("/chat", async (req, res) => {
1310
1422
  }
1311
1423
  }
1312
1424
  finally {
1313
- // Clean up per-request context and close MCP server
1425
+ // Clean up per-request context and close MCP servers
1314
1426
  delete sessionContext[requestId];
1315
1427
  await clientTools.instance?.close().catch(() => { });
1428
+ if (dashboardFs) {
1429
+ await dashboardFs.server.instance?.close().catch(() => { });
1430
+ }
1431
+ if (papercraneData) {
1432
+ await papercraneData.server.instance?.close().catch(() => { });
1433
+ }
1316
1434
  }
1317
1435
  });
1318
1436
  // =============================================================================
@@ -0,0 +1,23 @@
1
+ interface CreateOptions {
2
+ /** Base URL of the Papercrane API, e.g. "https://app.papercrane.ai". No trailing slash. */
3
+ apiBaseUrl: string;
4
+ /** Environment API key (Bearer token). Same key the rest of the agent uses. */
5
+ apiKey: string;
6
+ /** Share code from the chat session. Papercrane uses it to scope every call. */
7
+ shareCode: string;
8
+ /** Visitor session identifier (cookie-derived). Stamped on outbound calls for usage attribution. */
9
+ visitorSessionId: string;
10
+ }
11
+ /**
12
+ * Builds the papercrane-data MCP server for a shared-view chat session.
13
+ *
14
+ * Each tool is a thin HTTP wrapper around the share-scoped integrations
15
+ * endpoint at `/api/share/:shareCode/integrations/*`. Papercrane resolves the
16
+ * share's allowlist on every call (single source of truth, no staleness) and
17
+ * filters/gates the response server-side. The MCP server does no filtering of
18
+ * its own.
19
+ */
20
+ export declare function createPapercraneDataServer(options: CreateOptions): {
21
+ server: import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
22
+ };
23
+ export {};