lovelymango commited on
Commit
29148e2
·
verified ·
1 Parent(s): e82c21f

Deploy ephemeral-object issuer wiring (session 210)

Browse files
Dockerfile CHANGED
@@ -7,7 +7,7 @@ RUN npm ci --no-audit --no-fund && chown -R node:node /app
7
 
8
  # HF Spaces는 app_port(7860)로 트래픽을 보냄. 서버는 PORT env를 읽음.
9
  ENV PORT=7860
10
- ENV SERVICE_VERSION=24f010d
11
  EXPOSE 7860
12
 
13
  USER node
 
7
 
8
  # HF Spaces는 app_port(7860)로 트래픽을 보냄. 서버는 PORT env를 읽음.
9
  ENV PORT=7860
10
+ ENV SERVICE_VERSION=1dc600b
11
  EXPOSE 7860
12
 
13
  USER node
README.md CHANGED
@@ -14,15 +14,17 @@ Remote MCP editing service for HWPX documents (experimental v0).
14
 
15
  - Endpoints: `POST /mcp`, `POST /mcp-claude`, `GET /.well-known/oauth-protected-resource`, and `GET /oauth/consent`.
16
  - MCP endpoints accept the fixed bearer fallback or a Supabase-issued OAuth access token.
17
- - Documents are held in memory only (TTL, no persistence, no content logging).
18
 
19
  ## Required Space secrets
20
 
21
  - `MCP_AUTH_TOKEN` — Bearer token; the server refuses to start without it.
22
  - `PUBLIC_BASE_URL` — this Space's public URL (e.g. `https://<user>-<space>.hf.space`), used for expiring download URLs.
23
  - `OPENAI_APPS_CHALLENGE` — optional; ChatGPT Apps domain challenge value.
24
- - `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY` — optional together; enable OAuth discovery, JWT verification, and consent UI.
25
- - `SUPABASE_JWT_AUDIENCE` — optional; when set, bearer tokens must also carry this `aud` claim. Leave unset until the audience of real tokens has been observed: setting a wrong value rejects every caller.
 
 
26
 
27
  ## Platform-provided memory observation (provided by the platform — do not configure)
28
 
 
14
 
15
  - Endpoints: `POST /mcp`, `POST /mcp-claude`, `GET /.well-known/oauth-protected-resource`, and `GET /oauth/consent`.
16
  - MCP endpoints accept the fixed bearer fallback or a Supabase-issued OAuth access token.
17
+ - Supabase-authenticated remote sessions keep document generations in private, expiring object storage; the service hydrates bytes only while an operation runs and does not log document content. The fixed bearer fallback keeps the legacy in-process session path.
18
 
19
  ## Required Space secrets
20
 
21
  - `MCP_AUTH_TOKEN` — Bearer token; the server refuses to start without it.
22
  - `PUBLIC_BASE_URL` — this Space's public URL (e.g. `https://<user>-<space>.hf.space`), used for expiring download URLs.
23
  - `OPENAI_APPS_CHALLENGE` — optional; ChatGPT Apps domain challenge value.
24
+ - `SUPABASE_URL` and `SUPABASE_JWT_AUDIENCE` — optional together; enable OAuth discovery and JWT verification. Measure the real token audience before enabling this mode: a wrong value rejects every caller.
25
+ - `SUPABASE_PUBLISHABLE_KEY` — optional; together with `SUPABASE_URL`, enables the consent UI.
26
+ - `WEBAPP_EPHEMERAL_OBJECT_BASE_URL` — required when Supabase authentication is enabled; the separate HTTPS webapp origin that issues owner-bound upload and download capabilities. It must differ from `PUBLIC_BASE_URL`.
27
+ - `MCP_REQUIRED_SCOPES` — optional ASCII-space-delimited OAuth scope-token list. The default is the empty set; configure a measured baseline to make OAuth grant tokens missing that baseline fail with HTTP 403 `insufficient_scope`.
28
 
29
  ## Platform-provided memory observation (provided by the platform — do not configure)
30
 
packages/mcp-remote/src/config.ts CHANGED
@@ -1,8 +1,8 @@
1
  import type { RemoteMcpConfig, RemoteMcpEnv } from "./types.js";
2
  import { DEFAULT_SESSION_SWEEP_INTERVAL_MS } from "./runtime-defaults.js";
3
 
4
- const DEFAULT_SESSION_TTL_MS = 3_600_000;
5
- const DEFAULT_SESSION_IDLE_TTL_MS = 1_800_000;
6
  const DEFAULT_MAX_FILE_BYTES = 20 * 1024 * 1024;
7
  const DEFAULT_MAX_TARGETS = 5_000;
8
  const DEFAULT_MAX_CONCURRENT_SESSIONS = 4;
@@ -22,12 +22,25 @@ export function configFromEnv(env: RemoteMcpEnv = process.env): RemoteMcpConfig
22
  if (!publicBaseUrl) {
23
  throw new Error("PUBLIC_BASE_URL is required for expiring download URLs.");
24
  }
 
25
 
26
  const supabaseUrl = env.SUPABASE_URL ? normalizeHttpUrl(env.SUPABASE_URL, "SUPABASE_URL") : undefined;
27
  const supabaseJwtAudience = env.SUPABASE_JWT_AUDIENCE || undefined;
 
 
 
28
  if (Boolean(supabaseUrl) !== Boolean(supabaseJwtAudience)) {
29
  throw new Error("SUPABASE_URL and SUPABASE_JWT_AUDIENCE must be configured together.");
30
  }
 
 
 
 
 
 
 
 
 
31
 
32
  const maxConcurrentSessions = parsePositiveInteger(
33
  env.MAX_CONCURRENT_SESSIONS,
@@ -57,7 +70,8 @@ export function configFromEnv(env: RemoteMcpEnv = process.env): RemoteMcpConfig
57
 
58
  return {
59
  authToken,
60
- publicBaseUrl: normalizeBaseUrl(publicBaseUrl),
 
61
  sessionTtlMs: parsePositiveInteger(env.SESSION_TTL_MS, DEFAULT_SESSION_TTL_MS, "SESSION_TTL_MS"),
62
  sessionIdleTtlMs: parsePositiveInteger(
63
  env.SESSION_IDLE_TTL_MS,
@@ -91,11 +105,21 @@ export function configFromEnv(env: RemoteMcpEnv = process.env): RemoteMcpConfig
91
  supabaseUrl,
92
  supabasePublishableKey: env.SUPABASE_PUBLISHABLE_KEY,
93
  supabaseJwtAudience,
 
94
  serviceVersion: env.SERVICE_VERSION ?? "dev",
95
  statusAllowedOrigin: env.STATUS_ALLOWED_ORIGIN ?? "*"
96
  };
97
  }
98
 
 
 
 
 
 
 
 
 
 
99
  export function deriveMaxConcurrentSessionsPerPrincipal(maxConcurrentSessions: number): number {
100
  return Math.max(1, Math.floor(maxConcurrentSessions / 2));
101
  }
@@ -158,3 +182,11 @@ function normalizeHttpUrl(value: string, name: string): string {
158
  }
159
  return url.toString().replace(/\/$/u, "");
160
  }
 
 
 
 
 
 
 
 
 
1
  import type { RemoteMcpConfig, RemoteMcpEnv } from "./types.js";
2
  import { DEFAULT_SESSION_SWEEP_INTERVAL_MS } from "./runtime-defaults.js";
3
 
4
+ export const DEFAULT_SESSION_TTL_MS = 3_600_000;
5
+ export const DEFAULT_SESSION_IDLE_TTL_MS = 1_800_000;
6
  const DEFAULT_MAX_FILE_BYTES = 20 * 1024 * 1024;
7
  const DEFAULT_MAX_TARGETS = 5_000;
8
  const DEFAULT_MAX_CONCURRENT_SESSIONS = 4;
 
22
  if (!publicBaseUrl) {
23
  throw new Error("PUBLIC_BASE_URL is required for expiring download URLs.");
24
  }
25
+ const normalizedPublicBaseUrl = normalizeBaseUrl(publicBaseUrl);
26
 
27
  const supabaseUrl = env.SUPABASE_URL ? normalizeHttpUrl(env.SUPABASE_URL, "SUPABASE_URL") : undefined;
28
  const supabaseJwtAudience = env.SUPABASE_JWT_AUDIENCE || undefined;
29
+ const webappEphemeralObjectBaseUrl = env.WEBAPP_EPHEMERAL_OBJECT_BASE_URL
30
+ ? normalizeCredentialBearingUrl(env.WEBAPP_EPHEMERAL_OBJECT_BASE_URL, "WEBAPP_EPHEMERAL_OBJECT_BASE_URL")
31
+ : undefined;
32
  if (Boolean(supabaseUrl) !== Boolean(supabaseJwtAudience)) {
33
  throw new Error("SUPABASE_URL and SUPABASE_JWT_AUDIENCE must be configured together.");
34
  }
35
+ if (supabaseUrl && !webappEphemeralObjectBaseUrl) {
36
+ throw new Error("WEBAPP_EPHEMERAL_OBJECT_BASE_URL is required with Supabase authentication.");
37
+ }
38
+ if (!supabaseUrl && webappEphemeralObjectBaseUrl) {
39
+ throw new Error("WEBAPP_EPHEMERAL_OBJECT_BASE_URL requires Supabase authentication.");
40
+ }
41
+ if (webappEphemeralObjectBaseUrl === normalizedPublicBaseUrl) {
42
+ throw new Error("WEBAPP_EPHEMERAL_OBJECT_BASE_URL must be distinct from PUBLIC_BASE_URL.");
43
+ }
44
 
45
  const maxConcurrentSessions = parsePositiveInteger(
46
  env.MAX_CONCURRENT_SESSIONS,
 
70
 
71
  return {
72
  authToken,
73
+ publicBaseUrl: normalizedPublicBaseUrl,
74
+ requiredScopes: parseRequiredScopes(env.MCP_REQUIRED_SCOPES),
75
  sessionTtlMs: parsePositiveInteger(env.SESSION_TTL_MS, DEFAULT_SESSION_TTL_MS, "SESSION_TTL_MS"),
76
  sessionIdleTtlMs: parsePositiveInteger(
77
  env.SESSION_IDLE_TTL_MS,
 
105
  supabaseUrl,
106
  supabasePublishableKey: env.SUPABASE_PUBLISHABLE_KEY,
107
  supabaseJwtAudience,
108
+ webappEphemeralObjectBaseUrl,
109
  serviceVersion: env.SERVICE_VERSION ?? "dev",
110
  statusAllowedOrigin: env.STATUS_ALLOWED_ORIGIN ?? "*"
111
  };
112
  }
113
 
114
+ function parseRequiredScopes(value: string | undefined): readonly string[] {
115
+ if (value === undefined || value === "") return Object.freeze([]);
116
+ const scopeToken = "[!#-\\[\\]-~]+";
117
+ if (!new RegExp(`^${scopeToken}(?: ${scopeToken})*$`, "u").test(value)) {
118
+ throw new Error("MCP_REQUIRED_SCOPES must be an ASCII-space-delimited scope-token list.");
119
+ }
120
+ return Object.freeze([...new Set(value.split(" "))]);
121
+ }
122
+
123
  export function deriveMaxConcurrentSessionsPerPrincipal(maxConcurrentSessions: number): number {
124
  return Math.max(1, Math.floor(maxConcurrentSessions / 2));
125
  }
 
182
  }
183
  return url.toString().replace(/\/$/u, "");
184
  }
185
+
186
+ function normalizeCredentialBearingUrl(value: string, name: string): string {
187
+ const url = new URL(value);
188
+ if (url.protocol !== "https:" || url.username || url.password) {
189
+ throw new Error(`${name} must be an HTTPS URL without embedded credentials.`);
190
+ }
191
+ return url.toString().replace(/\/$/u, "");
192
+ }
packages/mcp-remote/src/ephemeral-object-client.ts ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ EphemeralObjectClient,
3
+ EphemeralObjectReference,
4
+ StoredEphemeralObject
5
+ } from "./types.js";
6
+
7
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
8
+
9
+ type UploadIssueResponse = Readonly<{
10
+ objectId: string;
11
+ byteLength: number;
12
+ retentionExpiresAt: string;
13
+ uploadUrl: string;
14
+ uploadUrlLifetime: "provider-fixed";
15
+ }>;
16
+
17
+ type DownloadIssueResponse = Readonly<{
18
+ objectId: string;
19
+ byteLength: number;
20
+ retentionExpiresAt: string;
21
+ downloadUrl: string;
22
+ downloadUrlExpiresAt: string;
23
+ }>;
24
+
25
+ export class WebappEphemeralObjectClient implements EphemeralObjectClient {
26
+ private readonly baseUrl: URL;
27
+
28
+ constructor(
29
+ baseUrl: string,
30
+ private readonly fetchImpl: typeof fetch = fetch
31
+ ) {
32
+ this.baseUrl = httpsUrl(baseUrl);
33
+ }
34
+
35
+ async storeAndDownload(bytes: Buffer, bearerToken: string): Promise<StoredEphemeralObject> {
36
+ assertBytes(bytes);
37
+ assertBearerToken(bearerToken);
38
+
39
+ const upload = parseUploadIssueResponse(await this.postJson(
40
+ "/api/ephemeral-objects/upload-issue",
41
+ { byteLength: bytes.byteLength },
42
+ bearerToken
43
+ ), bytes.byteLength);
44
+ const ref = referenceFromUpload(upload);
45
+
46
+ const uploadBody = Uint8Array.from(bytes).buffer;
47
+ const uploaded = await this.fetchImpl(providerUrl(upload.uploadUrl), {
48
+ method: "PUT",
49
+ redirect: "error",
50
+ headers: { "content-type": "application/octet-stream" },
51
+ body: uploadBody
52
+ });
53
+ if (!uploaded.ok) throw unavailable();
54
+
55
+ const access = await this.issueDownload(ref, bearerToken);
56
+ const downloaded = await this.fetchObject(access.downloadUrl, ref.byteLength);
57
+ if (!downloaded.equals(bytes)) throw unavailable();
58
+
59
+ return Object.freeze({
60
+ ref,
61
+ downloadUrl: access.downloadUrl,
62
+ downloadExpiresAt: access.downloadExpiresAt,
63
+ bytes: downloaded
64
+ });
65
+ }
66
+
67
+ async download(ref: EphemeralObjectReference, bearerToken: string): Promise<Buffer> {
68
+ assertReference(ref);
69
+ assertBearerToken(bearerToken);
70
+ const access = await this.issueDownload(ref, bearerToken);
71
+ return this.fetchObject(access.downloadUrl, ref.byteLength);
72
+ }
73
+
74
+ private async issueDownload(
75
+ ref: EphemeralObjectReference,
76
+ bearerToken: string
77
+ ): Promise<Readonly<{ downloadUrl: string; downloadExpiresAt: number }>> {
78
+ const response = parseDownloadIssueResponse(await this.postJson(
79
+ "/api/ephemeral-objects/download-issue",
80
+ { objectId: ref.objectId },
81
+ bearerToken
82
+ ));
83
+ const responseRetentionExpiresAt = timestamp(response.retentionExpiresAt);
84
+ if (response.objectId !== ref.objectId
85
+ || response.byteLength !== ref.byteLength
86
+ || responseRetentionExpiresAt !== ref.retentionExpiresAt) {
87
+ throw unavailable();
88
+ }
89
+ const downloadExpiresAt = timestamp(response.downloadUrlExpiresAt);
90
+ if (downloadExpiresAt > ref.retentionExpiresAt) throw unavailable();
91
+ return Object.freeze({
92
+ downloadUrl: providerUrl(response.downloadUrl),
93
+ downloadExpiresAt
94
+ });
95
+ }
96
+
97
+ private async postJson(path: string, body: Readonly<Record<string, unknown>>, bearerToken: string): Promise<unknown> {
98
+ const response = await this.fetchImpl(new URL(path, this.baseUrl), {
99
+ method: "POST",
100
+ redirect: "error",
101
+ headers: {
102
+ authorization: `Bearer ${bearerToken}`,
103
+ "content-type": "application/json"
104
+ },
105
+ body: JSON.stringify(body)
106
+ });
107
+ if (!response.ok) throw unavailable();
108
+ try {
109
+ return await response.json();
110
+ } catch {
111
+ throw unavailable();
112
+ }
113
+ }
114
+
115
+ private async fetchObject(url: string, expectedBytes: number): Promise<Buffer> {
116
+ const response = await this.fetchImpl(providerUrl(url), { method: "GET", redirect: "error" });
117
+ if (!response.ok) throw unavailable();
118
+ const bytes = Buffer.from(await response.arrayBuffer());
119
+ if (bytes.byteLength !== expectedBytes) {
120
+ bytes.fill(0);
121
+ throw unavailable();
122
+ }
123
+ return bytes;
124
+ }
125
+ }
126
+
127
+ function parseUploadIssueResponse(value: unknown, expectedBytes: number): UploadIssueResponse {
128
+ if (!isRecord(value)
129
+ || !UUID.test(string(value.objectId))
130
+ || value.byteLength !== expectedBytes
131
+ || !validTimestamp(value.retentionExpiresAt)
132
+ || typeof value.uploadUrl !== "string"
133
+ || value.uploadUrlLifetime !== "provider-fixed") {
134
+ throw unavailable();
135
+ }
136
+ return value as UploadIssueResponse;
137
+ }
138
+
139
+ function parseDownloadIssueResponse(value: unknown): DownloadIssueResponse {
140
+ if (!isRecord(value)
141
+ || !UUID.test(string(value.objectId))
142
+ || !positiveSafeInteger(value.byteLength)
143
+ || !validTimestamp(value.retentionExpiresAt)
144
+ || typeof value.downloadUrl !== "string"
145
+ || !validTimestamp(value.downloadUrlExpiresAt)) {
146
+ throw unavailable();
147
+ }
148
+ return value as DownloadIssueResponse;
149
+ }
150
+
151
+ function referenceFromUpload(response: UploadIssueResponse): EphemeralObjectReference {
152
+ return Object.freeze({
153
+ objectId: response.objectId,
154
+ byteLength: response.byteLength,
155
+ retentionExpiresAt: timestamp(response.retentionExpiresAt)
156
+ });
157
+ }
158
+
159
+ function assertReference(ref: EphemeralObjectReference): void {
160
+ if (!UUID.test(ref.objectId)
161
+ || !positiveSafeInteger(ref.byteLength)
162
+ || !Number.isSafeInteger(ref.retentionExpiresAt)
163
+ || ref.retentionExpiresAt <= 0) {
164
+ throw new TypeError("Ephemeral object reference is invalid.");
165
+ }
166
+ }
167
+
168
+ function assertBytes(bytes: Buffer): void {
169
+ if (!Buffer.isBuffer(bytes) || !positiveSafeInteger(bytes.byteLength)) {
170
+ throw new TypeError("Ephemeral object bytes are invalid.");
171
+ }
172
+ }
173
+
174
+ function assertBearerToken(value: string): void {
175
+ if (typeof value !== "string" || value.length === 0 || /[\u0000-\u0020\u007f]/u.test(value)) {
176
+ throw new TypeError("Ephemeral object authentication is invalid.");
177
+ }
178
+ }
179
+
180
+ function httpsUrl(value: string): URL {
181
+ const url = new URL(value);
182
+ if (url.protocol !== "https:" || url.username || url.password) {
183
+ throw new TypeError("Ephemeral object URL is invalid.");
184
+ }
185
+ return url;
186
+ }
187
+
188
+ function providerUrl(value: string): string {
189
+ return httpsUrl(value).toString();
190
+ }
191
+
192
+ function timestamp(value: string): number {
193
+ const parsed = Date.parse(value);
194
+ if (!Number.isSafeInteger(parsed)) throw unavailable();
195
+ return parsed;
196
+ }
197
+
198
+ function validTimestamp(value: unknown): value is string {
199
+ return typeof value === "string" && Number.isSafeInteger(Date.parse(value));
200
+ }
201
+
202
+ function positiveSafeInteger(value: unknown): value is number {
203
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
204
+ }
205
+
206
+ function isRecord(value: unknown): value is Record<string, unknown> {
207
+ return typeof value === "object" && value !== null && !Array.isArray(value);
208
+ }
209
+
210
+ function string(value: unknown): string {
211
+ return typeof value === "string" ? value : "";
212
+ }
213
+
214
+ function unavailable(): Error {
215
+ return new Error("Temporary object storage is unavailable.");
216
+ }
packages/mcp-remote/src/http.ts CHANGED
@@ -1,14 +1,22 @@
1
  import { createHash, timingSafeEqual } from "node:crypto";
2
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3
  import { getHeapStatistics } from "node:v8";
4
- import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
5
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
  import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose";
7
  import { writeAuditRecord, type AuditChannel } from "./audit.js";
8
  import { createMcpServer } from "./mcp.js";
9
  import { PeriodRateLimitTransport } from "./period-rate-limit-transport.js";
 
 
 
 
 
10
  import type { RemoteMcpService } from "./service.js";
11
- import { LOCAL_PRINCIPAL, type Principal, type RemoteMcpConfig } from "./types.js";
 
 
 
 
 
12
 
13
  export interface HttpServerOptions {
14
  service: RemoteMcpService;
@@ -63,16 +71,16 @@ export function createRemoteMcpHttpServer(options: HttpServerOptions): Server {
63
  return;
64
  }
65
  if (req.method === "GET" && req.url?.startsWith("/download/")) {
66
- const principal = await authenticate(req, res, options.config, jwks);
67
- if (!principal) return;
68
- handleDownload(options.service, req, res, principal);
69
  return;
70
  }
71
  if (req.method === "POST" && (req.url === "/mcp" || req.url === "/mcp-claude")) {
72
  const channel = req.url;
73
- const principal = await authenticate(req, res, options.config, jwks);
74
- if (!principal) return;
75
- await handleMcpRequest(options.service, req, res, principal, channel);
76
  return;
77
  }
78
  writeJson(res, 404, { error: "not-found" });
@@ -193,10 +201,16 @@ function handleDownload(
193
  service: RemoteMcpService,
194
  req: IncomingMessage,
195
  res: ServerResponse,
196
- principal: Principal
 
197
  ): void {
198
- let success = false;
 
199
  try {
 
 
 
 
200
  const token = decodeURIComponent((req.url ?? "").slice("/download/".length));
201
  if (!/^[A-Za-z0-9_-]{43,}$/u.test(token)) {
202
  writeJson(res, 404, { error: "download-not-found" });
@@ -209,26 +223,31 @@ function handleDownload(
209
  }
210
  let settled = false;
211
  const cleanup = (): void => {
212
- res.off("finish", finish);
213
- res.off("close", abort);
214
  res.off("error", abort);
215
  };
216
- const finish = (): void => {
217
  if (settled) return;
218
  settled = true;
219
  cleanup();
220
- if (res.writableFinished) reservation.finish();
221
- else reservation.abort();
 
 
 
 
 
 
 
222
  };
223
  const abort = (): void => {
224
- if (settled) return;
225
- settled = true;
226
- cleanup();
227
- reservation.abort();
228
  };
229
- res.once("finish", finish);
230
- res.once("close", abort);
231
  res.once("error", abort);
 
232
  try {
233
  res.writeHead(200, {
234
  "content-type": "application/octet-stream",
@@ -241,9 +260,10 @@ function handleDownload(
241
  abort();
242
  throw error;
243
  }
244
- success = true;
245
  } finally {
246
- writeAuditRecord(principal, "/download", "download", success);
 
 
247
  }
248
  }
249
 
@@ -252,7 +272,7 @@ async function authenticate(
252
  res: ServerResponse,
253
  config: RemoteMcpConfig,
254
  jwks: JWTVerifyGetKey | undefined
255
- ): Promise<Principal | undefined> {
256
  const authorization = req.headers.authorization;
257
  if (config.supabaseUrl && jwks && authorization?.startsWith("Bearer ")) {
258
  try {
@@ -260,12 +280,21 @@ async function authenticate(
260
  issuer: `${config.supabaseUrl}/auth/v1`,
261
  audience: config.supabaseJwtAudience
262
  });
263
- if (typeof payload.sub === "string" && payload.sub.length > 0) return payload.sub;
 
 
 
 
 
 
264
  } catch {
265
  // Authentication failures deliberately share one challenge without logging token or claims.
266
  }
267
  } else if (!config.supabaseUrl && authorization === `Bearer ${config.authToken}`) {
268
- return LOCAL_PRINCIPAL;
 
 
 
269
  }
270
  const parameters = [`realm="${config.publicBaseUrl}"`];
271
  if (config.supabaseUrl) {
@@ -360,26 +389,70 @@ async function handleMcpRequest(
360
  service: RemoteMcpService,
361
  req: IncomingMessage,
362
  res: ServerResponse,
363
- principal: Principal,
364
- channel: Exclude<AuditChannel, "/download">
 
365
  ): Promise<void> {
 
366
  req.headers.accept = req.headers.accept ?? "application/json, text/event-stream";
367
- const authenticatedRequest: IncomingMessage & { auth?: AuthInfo } = req;
368
- authenticatedRequest.auth = {
369
  token: req.headers.authorization!.slice("Bearer ".length),
370
- clientId: "",
371
- scopes: [],
372
- extra: { principal }
 
 
 
 
373
  };
374
- const mcp = createMcpServer(service, principal);
375
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
376
- const rateLimitedTransport = new PeriodRateLimitTransport(transport, service, channel);
 
 
 
 
 
 
377
  await mcp.connect(rateLimitedTransport);
378
  res.on("close", () => {
379
  void rateLimitedTransport.close();
380
  void mcp.close();
381
  });
382
- await transport.handleRequest(authenticatedRequest, res);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  }
384
 
385
  function writeJson(
 
1
  import { createHash, timingSafeEqual } from "node:crypto";
2
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3
  import { getHeapStatistics } from "node:v8";
 
 
4
  import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose";
5
  import { writeAuditRecord, type AuditChannel } from "./audit.js";
6
  import { createMcpServer } from "./mcp.js";
7
  import { PeriodRateLimitTransport } from "./period-rate-limit-transport.js";
8
+ import { decideScopeAccess } from "./scope-enforcement.js";
9
+ import {
10
+ ScopeAwareHttpTransport,
11
+ type ClaimAwareAuthInfo
12
+ } from "./scope-aware-http-transport.js";
13
  import type { RemoteMcpService } from "./service.js";
14
+ import {
15
+ LOCAL_PRINCIPAL,
16
+ type AuthContext,
17
+ type AuthenticatedPrincipal,
18
+ type RemoteMcpConfig
19
+ } from "./types.js";
20
 
21
  export interface HttpServerOptions {
22
  service: RemoteMcpService;
 
71
  return;
72
  }
73
  if (req.method === "GET" && req.url?.startsWith("/download/")) {
74
+ const authenticated = await authenticate(req, res, options.config, jwks);
75
+ if (!authenticated) return;
76
+ handleDownload(options.service, req, res, authenticated, options.config);
77
  return;
78
  }
79
  if (req.method === "POST" && (req.url === "/mcp" || req.url === "/mcp-claude")) {
80
  const channel = req.url;
81
+ const authenticated = await authenticate(req, res, options.config, jwks);
82
+ if (!authenticated) return;
83
+ await handleMcpRequest(options.service, req, res, authenticated, channel, options.config);
84
  return;
85
  }
86
  writeJson(res, 404, { error: "not-found" });
 
201
  service: RemoteMcpService,
202
  req: IncomingMessage,
203
  res: ServerResponse,
204
+ authenticated: AuthenticatedPrincipal,
205
+ config: RemoteMcpConfig
206
  ): void {
207
+ const { principal, authContext } = authenticated;
208
+ let auditDeferredUntilSettlement = false;
209
  try {
210
+ if (!decideScopeAccess(authContext, config.requiredScopes).allowed) {
211
+ writeInsufficientScope(res, config);
212
+ return;
213
+ }
214
  const token = decodeURIComponent((req.url ?? "").slice("/download/".length));
215
  if (!/^[A-Za-z0-9_-]{43,}$/u.test(token)) {
216
  writeJson(res, 404, { error: "download-not-found" });
 
223
  }
224
  let settled = false;
225
  const cleanup = (): void => {
226
+ res.off("finish", settleFromResponse);
227
+ res.off("close", settleFromResponse);
228
  res.off("error", abort);
229
  };
230
+ const settle = (deliveryCompleted: boolean): void => {
231
  if (settled) return;
232
  settled = true;
233
  cleanup();
234
+ try {
235
+ if (deliveryCompleted) reservation.finish();
236
+ else reservation.abort();
237
+ } finally {
238
+ writeAuditRecord(principal, "/download", "download", deliveryCompleted);
239
+ }
240
+ };
241
+ const settleFromResponse = (): void => {
242
+ settle(res.writableFinished);
243
  };
244
  const abort = (): void => {
245
+ settle(false);
 
 
 
246
  };
247
+ res.once("finish", settleFromResponse);
248
+ res.once("close", settleFromResponse);
249
  res.once("error", abort);
250
+ auditDeferredUntilSettlement = true;
251
  try {
252
  res.writeHead(200, {
253
  "content-type": "application/octet-stream",
 
260
  abort();
261
  throw error;
262
  }
 
263
  } finally {
264
+ if (!auditDeferredUntilSettlement) {
265
+ writeAuditRecord(principal, "/download", "download", false);
266
+ }
267
  }
268
  }
269
 
 
272
  res: ServerResponse,
273
  config: RemoteMcpConfig,
274
  jwks: JWTVerifyGetKey | undefined
275
+ ): Promise<AuthenticatedPrincipal | undefined> {
276
  const authorization = req.headers.authorization;
277
  if (config.supabaseUrl && jwks && authorization?.startsWith("Bearer ")) {
278
  try {
 
280
  issuer: `${config.supabaseUrl}/auth/v1`,
281
  audience: config.supabaseJwtAudience
282
  });
283
+ if (typeof payload.sub === "string" && payload.sub.length > 0) {
284
+ return {
285
+ principal: payload.sub,
286
+ authContext: authContextFromClaims(payload.client_id, payload.scope),
287
+ bearerToken: authorization.slice("Bearer ".length)
288
+ };
289
+ }
290
  } catch {
291
  // Authentication failures deliberately share one challenge without logging token or claims.
292
  }
293
  } else if (!config.supabaseUrl && authorization === `Bearer ${config.authToken}`) {
294
+ return {
295
+ principal: LOCAL_PRINCIPAL,
296
+ authContext: authContextFromClaims(undefined, undefined)
297
+ };
298
  }
299
  const parameters = [`realm="${config.publicBaseUrl}"`];
300
  if (config.supabaseUrl) {
 
389
  service: RemoteMcpService,
390
  req: IncomingMessage,
391
  res: ServerResponse,
392
+ authenticated: AuthenticatedPrincipal,
393
+ channel: Exclude<AuditChannel, "/download">,
394
+ config: RemoteMcpConfig
395
  ): Promise<void> {
396
+ const { principal, authContext } = authenticated;
397
  req.headers.accept = req.headers.accept ?? "application/json, text/event-stream";
398
+ const transport = new ScopeAwareHttpTransport();
399
+ const authInfo: ClaimAwareAuthInfo = {
400
  token: req.headers.authorization!.slice("Bearer ".length),
401
+ ...(typeof authContext.clientId === "string" ? { clientId: authContext.clientId } : {}),
402
+ scopes: authContext.scopes === undefined ? [] : [...authContext.scopes],
403
+ extra: {
404
+ principal,
405
+ authContext,
406
+ rejectInsufficientScope: () => transport.rejectInsufficientScope()
407
+ }
408
  };
409
+ const mcp = createMcpServer(service, principal, authenticated.bearerToken
410
+ ? Object.freeze({ bearerToken: authenticated.bearerToken })
411
+ : undefined);
412
+ const rateLimitedTransport = new PeriodRateLimitTransport(
413
+ transport,
414
+ service,
415
+ channel,
416
+ config.requiredScopes
417
+ );
418
  await mcp.connect(rateLimitedTransport);
419
  res.on("close", () => {
420
  void rateLimitedTransport.close();
421
  void mcp.close();
422
  });
423
+ await transport.handleRequest(
424
+ req,
425
+ res,
426
+ authInfo,
427
+ () => writeInsufficientScope(res, config)
428
+ );
429
+ }
430
+
431
+ function authContextFromClaims(clientId: unknown, scope: unknown): AuthContext {
432
+ return {
433
+ clientId,
434
+ scope,
435
+ scopes: typeof scope === "string"
436
+ ? new Set(scope.split(" ").filter((value) => value.length > 0))
437
+ : undefined
438
+ };
439
+ }
440
+
441
+ function writeInsufficientScope(res: ServerResponse, config: RemoteMcpConfig): void {
442
+ if (res.headersSent) return;
443
+ const parameters = [
444
+ `realm="${config.publicBaseUrl}"`,
445
+ ...(config.supabaseUrl
446
+ ? [`resource_metadata="${config.publicBaseUrl}/.well-known/oauth-protected-resource"`]
447
+ : []),
448
+ 'error="insufficient_scope"',
449
+ ...(config.requiredScopes.length > 0
450
+ ? [`scope="${config.requiredScopes.join(" ")}"`]
451
+ : [])
452
+ ];
453
+ writeJson(res, 403, { error: "insufficient_scope" }, {
454
+ "WWW-Authenticate": `Bearer ${parameters.join(", ")}`
455
+ });
456
  }
457
 
458
  function writeJson(
packages/mcp-remote/src/index.ts CHANGED
@@ -1,10 +1,12 @@
1
  #!/usr/bin/env tsx
2
  import { writeSync } from "node:fs";
3
  import { configFromEnv, portFromEnv } from "./config.js";
 
4
  import { createRemoteMcpHttpServer } from "./http.js";
5
  import { installProcessLifecycle } from "./lifecycle.js";
6
  import { formatContainerMemoryObservation } from "./memory-observation.js";
7
  import { RemoteMcpService } from "./service.js";
 
8
 
9
  export { configFromEnv, portFromEnv } from "./config.js";
10
  export { createRemoteMcpHttpServer } from "./http.js";
@@ -17,11 +19,28 @@ export {
17
  } from "./service.js";
18
  export { SessionStore, randomToken, systemClock } from "./store.js";
19
  export { downloadDocumentUrl, isPrivateAddress } from "./download.js";
 
20
  export type * from "./types.js";
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  function main(): void {
23
  const config = configFromEnv();
24
- const service = new RemoteMcpService({ config });
 
25
  const server = createRemoteMcpHttpServer({ service, config });
26
  const lifecycle = installProcessLifecycle({
27
  server,
 
1
  #!/usr/bin/env tsx
2
  import { writeSync } from "node:fs";
3
  import { configFromEnv, portFromEnv } from "./config.js";
4
+ import { WebappEphemeralObjectClient } from "./ephemeral-object-client.js";
5
  import { createRemoteMcpHttpServer } from "./http.js";
6
  import { installProcessLifecycle } from "./lifecycle.js";
7
  import { formatContainerMemoryObservation } from "./memory-observation.js";
8
  import { RemoteMcpService } from "./service.js";
9
+ import type { EphemeralObjectClient, RemoteMcpConfig } from "./types.js";
10
 
11
  export { configFromEnv, portFromEnv } from "./config.js";
12
  export { createRemoteMcpHttpServer } from "./http.js";
 
19
  } from "./service.js";
20
  export { SessionStore, randomToken, systemClock } from "./store.js";
21
  export { downloadDocumentUrl, isPrivateAddress } from "./download.js";
22
+ export { WebappEphemeralObjectClient } from "./ephemeral-object-client.js";
23
  export type * from "./types.js";
24
 
25
+ export function ephemeralObjectClientFromConfig(
26
+ config: RemoteMcpConfig,
27
+ fetchImpl: typeof fetch = fetch
28
+ ): EphemeralObjectClient | undefined {
29
+ if (config.supabaseUrl && !config.webappEphemeralObjectBaseUrl) {
30
+ throw new Error("Authenticated remote MCP requires temporary object storage.");
31
+ }
32
+ if (!config.supabaseUrl && config.webappEphemeralObjectBaseUrl) {
33
+ throw new Error("Temporary object storage requires authenticated remote MCP.");
34
+ }
35
+ return config.webappEphemeralObjectBaseUrl
36
+ ? new WebappEphemeralObjectClient(config.webappEphemeralObjectBaseUrl, fetchImpl)
37
+ : undefined;
38
+ }
39
+
40
  function main(): void {
41
  const config = configFromEnv();
42
+ const ephemeralObjects = ephemeralObjectClientFromConfig(config);
43
+ const service = new RemoteMcpService({ config, ephemeralObjects });
44
  const server = createRemoteMcpHttpServer({ service, config });
45
  const lifecycle = installProcessLifecycle({
46
  server,
packages/mcp-remote/src/mcp.ts CHANGED
@@ -1,8 +1,13 @@
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
 
2
  import { documentToolInputSchemas } from "./mcp-schemas.js";
3
  import { UNKNOWN_TOOL_AUDIT_ACTION, type AuditAction } from "./audit-contract.js";
4
  import type { RemoteMcpService } from "./service.js";
5
- import { LOCAL_PRINCIPAL, type Principal } from "./types.js";
 
 
 
 
6
 
7
  type ToolHint = "readOnlyHint" | "destructiveHint" | "idempotentHint" | "openWorldHint";
8
  export type RegisteredToolName = Exclude<AuditAction, "download" | typeof UNKNOWN_TOOL_AUDIT_ACTION>;
@@ -24,19 +29,19 @@ export const TOOL_HINT_JUSTIFICATIONS = {
24
  readOnlyHint: "Inspects active session bytes and returns an aggregate privacy-minimized snapshot without editing the document.",
25
  destructiveHint: "Leaves session bytes, rollback state, downloads, and source resources unchanged.",
26
  idempotentHint: "Reanalyzes the same active session bytes without changing the environment.",
27
- openWorldHint: "Uses the local adapter and session store only and makes no external network request."
28
  },
29
  apply_edits: {
30
  readOnlyHint: "Changes the private session document and edit sequence and creates an expiring edited download.",
31
  destructiveHint: "Edits only a private working copy while preserving the supplied source and retaining a one-step rollback.",
32
  idempotentHint: "A repeated successful edit changes session state again and can advance edit and structure versions.",
33
- openWorldHint: "Applies edits inside the existing private session and does not contact or modify an external host."
34
  },
35
  rollback: {
36
  readOnlyHint: "Replaces the private session document with its previous bytes, advances the edit sequence, and creates a download.",
37
  destructiveHint: "Changes only the private session copy and leaves the user's supplied source untouched.",
38
  idempotentHint: "Consumes a one-step rollback token on success, so repeating the same token fails instead of having no effect.",
39
- openWorldHint: "Uses stored session and rollback bytes only and performs no external network operation."
40
  },
41
  close_document: {
42
  readOnlyHint: "Deletes the addressed private document session and its retained state when that owned session exists.",
@@ -54,9 +59,29 @@ export function isRegisteredToolName(value: unknown): value is RegisteredToolNam
54
  return typeof value === "string" && REGISTERED_TOOL_NAMES.has(value);
55
  }
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  export function createMcpServer(
58
  service: RemoteMcpService,
59
- principal: Principal = LOCAL_PRINCIPAL
 
60
  ): McpServer {
61
  const server = new McpServer({
62
  name: "hwpxkit-mcp-remote",
@@ -70,7 +95,7 @@ export function createMcpServer(
70
  "open_document",
71
  {
72
  title: "Open HWPX Document",
73
- description: "Download an HWPX file from a provided file object or URL and create an editing session that expires after 30 minutes of inactivity and always within 1 hour of opening.",
74
  inputSchema: documentToolInputSchemas.open_document,
75
  annotations: {
76
  readOnlyHint: false,
@@ -89,7 +114,7 @@ export function createMcpServer(
89
  if (url !== undefined && file !== undefined) {
90
  throw new Error("Both a file and URL were provided; provide exactly one document source.");
91
  }
92
- return toolResult(await service.openDocument({ url: url ?? file!.download_url }, principal));
93
  }
94
  );
95
 
@@ -106,7 +131,7 @@ export function createMcpServer(
106
  openWorldHint: false
107
  }
108
  },
109
- async (input) => toolResult({ targets: service.listTargets(input, principal) })
110
  );
111
 
112
  registerTool(
@@ -122,7 +147,7 @@ export function createMcpServer(
122
  openWorldHint: false
123
  }
124
  },
125
- async (input) => toolResult(await service.analyzeDocument(input, principal))
126
  );
127
 
128
  registerTool(
@@ -138,7 +163,7 @@ export function createMcpServer(
138
  openWorldHint: false
139
  }
140
  },
141
- async (input) => toolResult(await service.applyEdits(input, principal))
142
  );
143
 
144
  registerTool(
@@ -154,7 +179,7 @@ export function createMcpServer(
154
  openWorldHint: false
155
  }
156
  },
157
- async (input) => toolResult(await service.rollback(input, principal))
158
  );
159
 
160
  registerTool(
@@ -170,7 +195,7 @@ export function createMcpServer(
170
  openWorldHint: false
171
  }
172
  },
173
- async (input) => toolResult(await service.closeDocument(input, principal))
174
  );
175
 
176
  return server;
 
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { DEFAULT_SESSION_IDLE_TTL_MS, DEFAULT_SESSION_TTL_MS } from "./config.js";
3
  import { documentToolInputSchemas } from "./mcp-schemas.js";
4
  import { UNKNOWN_TOOL_AUDIT_ACTION, type AuditAction } from "./audit-contract.js";
5
  import type { RemoteMcpService } from "./service.js";
6
+ import {
7
+ LOCAL_PRINCIPAL,
8
+ type EphemeralObjectRequestContext,
9
+ type Principal
10
+ } from "./types.js";
11
 
12
  type ToolHint = "readOnlyHint" | "destructiveHint" | "idempotentHint" | "openWorldHint";
13
  export type RegisteredToolName = Exclude<AuditAction, "download" | typeof UNKNOWN_TOOL_AUDIT_ACTION>;
 
29
  readOnlyHint: "Inspects active session bytes and returns an aggregate privacy-minimized snapshot without editing the document.",
30
  destructiveHint: "Leaves session bytes, rollback state, downloads, and source resources unchanged.",
31
  idempotentHint: "Reanalyzes the same active session bytes without changing the environment.",
32
+ openWorldHint: "In object mode, performs network I/O only to the configured webapp issuer and owner-bound private storage object; it never accesses arbitrary external resources."
33
  },
34
  apply_edits: {
35
  readOnlyHint: "Changes the private session document and edit sequence and creates an expiring edited download.",
36
  destructiveHint: "Edits only a private working copy while preserving the supplied source and retaining a one-step rollback.",
37
  idempotentHint: "A repeated successful edit changes session state again and can advance edit and structure versions.",
38
+ openWorldHint: "In object mode, performs read-and-write network I/O only through the configured webapp issuer and owner-bound private storage objects; it never accesses arbitrary external resources."
39
  },
40
  rollback: {
41
  readOnlyHint: "Replaces the private session document with its previous bytes, advances the edit sequence, and creates a download.",
42
  destructiveHint: "Changes only the private session copy and leaves the user's supplied source untouched.",
43
  idempotentHint: "Consumes a one-step rollback token on success, so repeating the same token fails instead of having no effect.",
44
+ openWorldHint: "In object mode, performs rollback network I/O only through the configured webapp issuer and owner-bound private storage objects; it never accesses arbitrary external resources."
45
  },
46
  close_document: {
47
  readOnlyHint: "Deletes the addressed private document session and its retained state when that owned session exists.",
 
59
  return typeof value === "string" && REGISTERED_TOOL_NAMES.has(value);
60
  }
61
 
62
+ function formatSessionLifetime(durationMs: number): string {
63
+ if (durationMs % 3_600_000 === 0) {
64
+ const hours = durationMs / 3_600_000;
65
+ return `${hours} ${hours === 1 ? "hour" : "hours"}`;
66
+ }
67
+ if (durationMs % 60_000 === 0) {
68
+ const minutes = durationMs / 60_000;
69
+ return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
70
+ }
71
+ const seconds = durationMs / 1_000;
72
+ return `${seconds} ${seconds === 1 ? "second" : "seconds"}`;
73
+ }
74
+
75
+ export function createOpenDocumentDescription(): string {
76
+ const idleLifetime = formatSessionLifetime(DEFAULT_SESSION_IDLE_TTL_MS);
77
+ const absoluteLifetime = formatSessionLifetime(DEFAULT_SESSION_TTL_MS);
78
+ return `Download an HWPX file from a provided file object or URL and create an editing session that expires after ${idleLifetime} of inactivity and always within ${absoluteLifetime} of opening.`;
79
+ }
80
+
81
  export function createMcpServer(
82
  service: RemoteMcpService,
83
+ principal: Principal = LOCAL_PRINCIPAL,
84
+ requestContext?: EphemeralObjectRequestContext
85
  ): McpServer {
86
  const server = new McpServer({
87
  name: "hwpxkit-mcp-remote",
 
95
  "open_document",
96
  {
97
  title: "Open HWPX Document",
98
+ description: createOpenDocumentDescription(),
99
  inputSchema: documentToolInputSchemas.open_document,
100
  annotations: {
101
  readOnlyHint: false,
 
114
  if (url !== undefined && file !== undefined) {
115
  throw new Error("Both a file and URL were provided; provide exactly one document source.");
116
  }
117
+ return toolResult(await service.openDocument({ url: url ?? file!.download_url }, principal, requestContext));
118
  }
119
  );
120
 
 
131
  openWorldHint: false
132
  }
133
  },
134
+ async (input) => toolResult({ targets: service.listTargets(input, principal, requestContext) })
135
  );
136
 
137
  registerTool(
 
147
  openWorldHint: false
148
  }
149
  },
150
+ async (input) => toolResult(await service.analyzeDocument(input, principal, requestContext))
151
  );
152
 
153
  registerTool(
 
163
  openWorldHint: false
164
  }
165
  },
166
+ async (input) => toolResult(await service.applyEdits(input, principal, requestContext))
167
  );
168
 
169
  registerTool(
 
179
  openWorldHint: false
180
  }
181
  },
182
+ async (input) => toolResult(await service.rollback(input, principal, requestContext))
183
  );
184
 
185
  registerTool(
 
195
  openWorldHint: false
196
  }
197
  },
198
+ async (input) => toolResult(await service.closeDocument(input, principal, requestContext))
199
  );
200
 
201
  return server;
packages/mcp-remote/src/period-rate-limit-transport.ts CHANGED
@@ -15,8 +15,9 @@ import {
15
  type AuditChannel
16
  } from "./audit.js";
17
  import { isRegisteredToolName, type RegisteredToolName } from "./mcp.js";
 
18
  import type { RemoteMcpService } from "./service.js";
19
- import type { Principal } from "./types.js";
20
 
21
  type PendingAudit = {
22
  principal: Principal;
@@ -31,7 +32,8 @@ export class PeriodRateLimitTransport implements Transport {
31
  constructor(
32
  private readonly inner: Transport,
33
  private readonly service: RemoteMcpService,
34
- private readonly channel: Exclude<AuditChannel, "/download">
 
35
  ) {}
36
 
37
  private readonly pendingAudits = new Map<JSONRPCRequest["id"], PendingAudit[]>();
@@ -75,6 +77,13 @@ export class PeriodRateLimitTransport implements Transport {
75
 
76
  const principal = transportPrincipal(extra);
77
  const action = auditAction(toolName(message));
 
 
 
 
 
 
 
78
  const rejectWhenExceeded = action !== "close_document";
79
  try {
80
  this.service.recordToolCall(principal, rejectWhenExceeded);
@@ -94,6 +103,28 @@ export class PeriodRateLimitTransport implements Transport {
94
  }
95
  }
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  function isToolCallRequest(message: JSONRPCMessage): message is JSONRPCRequest & { method: "tools/call" } {
98
  return "id" in message && "method" in message && message.method === "tools/call";
99
  }
 
15
  type AuditChannel
16
  } from "./audit.js";
17
  import { isRegisteredToolName, type RegisteredToolName } from "./mcp.js";
18
+ import { decideScopeAccess } from "./scope-enforcement.js";
19
  import type { RemoteMcpService } from "./service.js";
20
+ import type { AuthContext, Principal } from "./types.js";
21
 
22
  type PendingAudit = {
23
  principal: Principal;
 
32
  constructor(
33
  private readonly inner: Transport,
34
  private readonly service: RemoteMcpService,
35
+ private readonly channel: Exclude<AuditChannel, "/download">,
36
+ private readonly requiredScopes: readonly string[]
37
  ) {}
38
 
39
  private readonly pendingAudits = new Map<JSONRPCRequest["id"], PendingAudit[]>();
 
77
 
78
  const principal = transportPrincipal(extra);
79
  const action = auditAction(toolName(message));
80
+ const authContext = transportAuthContext(extra);
81
+ if (!decideScopeAccess(authContext, this.requiredScopes).allowed) {
82
+ this.service.recordToolCall(principal, false);
83
+ writeAuditRecord(principal, this.channel, action, false);
84
+ rejectInsufficientScope(extra);
85
+ return;
86
+ }
87
  const rejectWhenExceeded = action !== "close_document";
88
  try {
89
  this.service.recordToolCall(principal, rejectWhenExceeded);
 
103
  }
104
  }
105
 
106
+ function transportAuthContext(extra: MessageExtraInfo | undefined): AuthContext {
107
+ const authContext = extra?.authInfo?.extra?.authContext;
108
+ if (!isAuthContext(authContext)) {
109
+ throw new Error("Authenticated claims are unavailable at the MCP transport boundary.");
110
+ }
111
+ return authContext;
112
+ }
113
+
114
+ function isAuthContext(value: unknown): value is AuthContext {
115
+ return typeof value === "object" && value !== null &&
116
+ "clientId" in value && "scope" in value && "scopes" in value &&
117
+ (value.scopes === undefined || value.scopes instanceof Set);
118
+ }
119
+
120
+ function rejectInsufficientScope(extra: MessageExtraInfo | undefined): void {
121
+ const reject = extra?.authInfo?.extra?.rejectInsufficientScope;
122
+ if (typeof reject !== "function") {
123
+ throw new Error("Scope rejection is unavailable at the MCP transport boundary.");
124
+ }
125
+ reject();
126
+ }
127
+
128
  function isToolCallRequest(message: JSONRPCMessage): message is JSONRPCRequest & { method: "tools/call" } {
129
  return "id" in message && "method" in message && message.method === "tools/call";
130
  }
packages/mcp-remote/src/scope-aware-http-transport.ts ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Readable } from "node:stream";
2
+ import { pipeline } from "node:stream/promises";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
5
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
6
+ import type {
7
+ Transport,
8
+ TransportSendOptions
9
+ } from "@modelcontextprotocol/sdk/shared/transport.js";
10
+ import type { JSONRPCMessage, MessageExtraInfo } from "@modelcontextprotocol/sdk/types.js";
11
+
12
+ export type ClaimAwareAuthInfo = Omit<AuthInfo, "clientId"> & { clientId?: string };
13
+
14
+ export class ScopeAwareHttpTransport implements Transport {
15
+ private readonly inner = new WebStandardStreamableHTTPServerTransport({
16
+ sessionIdGenerator: undefined
17
+ });
18
+ private scopeRejected = false;
19
+
20
+ get sessionId(): string | undefined {
21
+ return this.inner.sessionId;
22
+ }
23
+
24
+ set onclose(handler: (() => void) | undefined) {
25
+ this.inner.onclose = handler;
26
+ }
27
+
28
+ get onclose(): (() => void) | undefined {
29
+ return this.inner.onclose;
30
+ }
31
+
32
+ set onerror(handler: ((error: Error) => void) | undefined) {
33
+ this.inner.onerror = handler;
34
+ }
35
+
36
+ get onerror(): ((error: Error) => void) | undefined {
37
+ return this.inner.onerror;
38
+ }
39
+
40
+ set onmessage(
41
+ handler: (<T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void) | undefined
42
+ ) {
43
+ this.inner.onmessage = handler;
44
+ }
45
+
46
+ get onmessage(): (<T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void) | undefined {
47
+ return this.inner.onmessage;
48
+ }
49
+
50
+ async start(): Promise<void> {
51
+ await this.inner.start();
52
+ }
53
+
54
+ async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
55
+ await this.inner.send(message, options);
56
+ }
57
+
58
+ async close(): Promise<void> {
59
+ await this.inner.close();
60
+ }
61
+
62
+ rejectInsufficientScope(): void {
63
+ this.scopeRejected = true;
64
+ }
65
+
66
+ async handleRequest(
67
+ req: IncomingMessage,
68
+ res: ServerResponse,
69
+ authInfo: ClaimAwareAuthInfo,
70
+ writeInsufficientScope: () => void
71
+ ): Promise<void> {
72
+ // The SDK type requires clientId even though verified session/static tokens have no such claim.
73
+ // Its runtime transport does not validate the shape; keep absence truthful at our boundary.
74
+ const response = await this.inner.handleRequest(toWebRequest(req), { authInfo: authInfo as AuthInfo });
75
+ if (this.scopeRejected) {
76
+ await response.body?.cancel().catch(() => {});
77
+ writeInsufficientScope();
78
+ return;
79
+ }
80
+ await writeWebResponse(res, response);
81
+ }
82
+ }
83
+
84
+ function toWebRequest(req: IncomingMessage): Request {
85
+ const headers = new Headers();
86
+ for (const [name, value] of Object.entries(req.headers)) {
87
+ if (Array.isArray(value)) {
88
+ for (const item of value) headers.append(name, item);
89
+ } else if (value !== undefined) {
90
+ headers.set(name, value);
91
+ }
92
+ }
93
+ const method = req.method ?? "GET";
94
+ const init: RequestInit & { duplex?: "half" } = { method, headers };
95
+ if (method !== "GET" && method !== "HEAD") {
96
+ init.body = Readable.toWeb(req) as unknown as BodyInit;
97
+ init.duplex = "half";
98
+ }
99
+ return new Request(new URL(req.url ?? "/", requestOrigin(req)), init);
100
+ }
101
+
102
+ function requestOrigin(req: IncomingMessage): string {
103
+ const forwardedProto = req.headers["x-forwarded-proto"];
104
+ const protocol = typeof forwardedProto === "string" && forwardedProto.split(",", 1)[0] === "https"
105
+ ? "https"
106
+ : "http";
107
+ const host = req.headers.host ?? "localhost";
108
+ return `${protocol}://${host}`;
109
+ }
110
+
111
+ async function writeWebResponse(res: ServerResponse, response: Response): Promise<void> {
112
+ const headers: Record<string, string> = {};
113
+ response.headers.forEach((value, name) => {
114
+ headers[name] = value;
115
+ });
116
+ res.writeHead(response.status, headers);
117
+ if (response.body === null) {
118
+ res.end();
119
+ return;
120
+ }
121
+ await pipeline(Readable.fromWeb(response.body as never), res);
122
+ }
packages/mcp-remote/src/scope-enforcement.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AuthContext } from "./types.js";
2
+
3
+ export interface ScopeDecision {
4
+ allowed: boolean;
5
+ }
6
+
7
+ export function decideScopeAccess(
8
+ authContext: AuthContext,
9
+ requiredScopes: readonly string[]
10
+ ): ScopeDecision {
11
+ if (authContext.scope !== undefined && typeof authContext.scope !== "string") {
12
+ return { allowed: false };
13
+ }
14
+ if (authContext.clientId === undefined) return { allowed: true };
15
+ if (requiredScopes.length === 0) return { allowed: true };
16
+ if (authContext.scopes === undefined) return { allowed: false };
17
+ return { allowed: requiredScopes.every((scope) => authContext.scopes!.has(scope)) };
18
+ }
packages/mcp-remote/src/service.ts CHANGED
@@ -26,6 +26,7 @@ import { listRevisionTargets, summarizeRevisionTargets, toPatchTarget, type Revi
26
  import { downloadDocumentUrl } from "#mcp-remote/download";
27
  import { deriveMaxConcurrentSessionsPerPrincipal } from "./config.js";
28
  import {
 
29
  AdmissionGuardError,
30
  randomToken,
31
  SessionStore,
@@ -37,6 +38,9 @@ import type {
37
  Clock,
38
  DocumentAnalysisSnapshot,
39
  DocumentSession,
 
 
 
40
  PublicTarget,
41
  Principal,
42
  RemoteEdit,
@@ -52,6 +56,7 @@ export interface RemoteMcpServiceOptions {
52
  config: RemoteMcpConfig;
53
  store?: SessionStore;
54
  clock?: Clock;
 
55
  }
56
 
57
  export interface OpenDocumentResult {
@@ -173,6 +178,7 @@ export class RemoteMcpService {
173
  readonly store: SessionStore;
174
  private readonly adapter = new DirectXmlAdapter();
175
  private readonly clock: Clock;
 
176
 
177
  private get inFlightSessionCount(): number {
178
  return this.store.getAccountingTotals().inFlightCount;
@@ -198,7 +204,8 @@ export class RemoteMcpService {
198
 
199
  async openDocument(
200
  input: { url: string },
201
- principal: Principal = LOCAL_PRINCIPAL
 
202
  ): Promise<OpenDocumentResult> {
203
  this.assertInputBytesAllowed();
204
  const reservation = this.store.reserveSession(
@@ -212,7 +219,7 @@ export class RemoteMcpService {
212
  allowInsecureLocalhost: this.options.config.allowInsecureLocalhostDownloads
213
  });
214
  reservation.narrow(bytes.byteLength);
215
- return await this.createReservedSession(bytes, principal, reservation);
216
  } finally {
217
  reservation.release();
218
  }
@@ -220,12 +227,13 @@ export class RemoteMcpService {
220
 
221
  async createSession(
222
  bytes: Buffer,
223
- principal: Principal = LOCAL_PRINCIPAL
 
224
  ): Promise<OpenDocumentResult> {
225
  this.assertInputBytesAllowed(bytes);
226
  const reservation = this.store.reserveSession(principal, bytes.byteLength, this.sessionLimits());
227
  try {
228
- return await this.createReservedSession(bytes, principal, reservation);
229
  } finally {
230
  reservation.release();
231
  }
@@ -234,7 +242,8 @@ export class RemoteMcpService {
234
  private async createReservedSession(
235
  bytes: Buffer,
236
  principal: Principal,
237
- reservation: WorkReservation
 
238
  ): Promise<OpenDocumentResult> {
239
  this.assertInputBytesAllowed(bytes);
240
  const operationReservation = this.store.reserveOperation(principal, this.sessionLimits());
@@ -271,21 +280,35 @@ export class RemoteMcpService {
271
  if (!validation.valid) {
272
  throw new Error(validation.errors[0]?.message ?? "inspect failed");
273
  }
 
 
 
 
 
 
 
 
 
 
274
  const targetIndex = buildRemoteTargetIndexFromSelections(selectRemoteTargetSelections(bytes));
 
275
  const openedAt = this.clock.now();
 
276
  const session: DocumentSession = {
277
  docHandle: randomToken(),
278
  ownerPrincipal: principal,
279
- bytes,
 
280
  structureVersion: 1,
281
  editSeq: 0,
282
  targetIndex,
 
283
  structuralEditCount: 0,
284
  rollbackEntries: new Map(),
285
  downloadArtifacts: new Map(),
286
  paragraphAnchors: injected.state,
287
  absoluteTtlMs: this.options.config.sessionTtlMs,
288
- expiresAt: openedAt + this.options.config.sessionTtlMs,
289
  idleTtlMs: this.options.config.sessionIdleTtlMs,
290
  idleExpiresAt: openedAt + this.options.config.sessionIdleTtlMs
291
  };
@@ -294,6 +317,8 @@ export class RemoteMcpService {
294
  } catch (error) {
295
  session.bytes.fill(0);
296
  throw error;
 
 
297
  }
298
  return {
299
  docHandle: session.docHandle,
@@ -339,9 +364,17 @@ export class RemoteMcpService {
339
 
340
  listTargets(
341
  input: { docHandle: string; kind?: RemoteTargetKind; fillableOnly?: boolean },
342
- principal: Principal = LOCAL_PRINCIPAL
 
343
  ): PublicTarget[] {
344
  const session = this.requireSession(input.docHandle, principal);
 
 
 
 
 
 
 
345
  const ordinals = session.targetIndex
346
  .filter((entry) => input.kind === undefined || entry.kind === input.kind)
347
  .map((entry) => entry.ordinal);
@@ -358,12 +391,15 @@ export class RemoteMcpService {
358
 
359
  async analyzeDocument(
360
  input: { docHandle: string },
361
- principal: Principal = LOCAL_PRINCIPAL
 
362
  ): Promise<DocumentAnalysisSnapshot> {
363
  const session = this.requireSessionLease(input.docHandle, principal);
364
  let operationReservation: WorkReservation | undefined;
 
365
  try {
366
  operationReservation = this.store.reserveOperation(principal, this.sessionLimits());
 
367
  const inspect = await this.adapter.inspect(session.bytes);
368
  this.assertSessionActive(session);
369
  if (!inspect.inspectSuccess) {
@@ -376,6 +412,7 @@ export class RemoteMcpService {
376
  )
377
  );
378
  } finally {
 
379
  operationReservation?.release();
380
  this.store.releaseSession(session);
381
  }
@@ -383,13 +420,16 @@ export class RemoteMcpService {
383
 
384
  async applyEdits(
385
  input: ApplyEditsInput,
386
- principal: Principal = LOCAL_PRINCIPAL
 
387
  ): Promise<ApplyEditsResult> {
388
  const session = this.requireSessionLease(input.docHandle, principal);
389
  let operationReservation: WorkReservation | undefined;
390
  let unownedRollbackBytes: Buffer | undefined;
 
391
  try {
392
  operationReservation = this.store.reserveOperation(principal, this.sessionLimits());
 
393
  if (input.edits.length > this.options.config.maxEditsPerBatch) {
394
  return {
395
  success: false,
@@ -557,7 +597,7 @@ export class RemoteMcpService {
557
  return { ...base, operation: "editEquationScript", latex: edit.value! };
558
  });
559
 
560
- unownedRollbackBytes = structural ? Buffer.from(session.bytes) : undefined;
561
  const patch = await this.adapter.patch(session.bytes, operations, {
562
  paragraphAnchors: session.paragraphAnchors,
563
  retainPackageForExport: true
@@ -585,6 +625,67 @@ export class RemoteMcpService {
585
  : base;
586
  }
587
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
  replaceSessionBytes(session, patch.output);
589
  session.editSeq += 1;
590
  session.structuralEditCount += structuralEdits.length;
@@ -636,6 +737,7 @@ export class RemoteMcpService {
636
  try {
637
  unownedRollbackBytes?.fill(0);
638
  } finally {
 
639
  operationReservation?.release();
640
  this.store.releaseSession(session);
641
  }
@@ -644,12 +746,16 @@ export class RemoteMcpService {
644
 
645
  async rollback(
646
  input: { docHandle: string; rollbackToken: string },
647
- principal: Principal = LOCAL_PRINCIPAL
 
648
  ): Promise<RollbackResult> {
649
  const session = this.requireSessionLease(input.docHandle, principal);
650
  let operationReservation: WorkReservation | undefined;
 
 
651
  try {
652
  operationReservation = this.store.reserveOperation(principal, this.sessionLimits());
 
653
  const rollback = this.store.getRollback(session, input.rollbackToken, principal);
654
  if (!rollback) {
655
  return { success: false, code: "rollback-not-found", message: "Rollback token was not found or has expired; call rollback with the rollbackToken returned by the edit you want to undo." };
@@ -670,10 +776,17 @@ export class RemoteMcpService {
670
  };
671
  }
672
 
673
- let rollbackPackage = rollback.bytesBefore ? readHwpxPackage(rollback.bytesBefore) : undefined;
 
 
 
 
 
 
 
674
  if (rollbackPackage) syncActiveParagraphAnchorsInPackage(rollbackPackage, session.paragraphAnchors);
675
- const patch = rollback.bytesBefore
676
- ? { success: true as const, output: Buffer.from(rollback.bytesBefore), applied: [], errors: [], packageForExport: rollbackPackage }
677
  : await this.adapter.patch(session.bytes, rollback.inverse, {
678
  paragraphAnchors: session.paragraphAnchors,
679
  retainPackageForExport: true
@@ -688,6 +801,48 @@ export class RemoteMcpService {
688
  return base;
689
  }
690
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
691
  replaceSessionBytes(session, patch.output);
692
  session.editSeq += 1;
693
  session.structuralEditCount += structuralEditCount;
@@ -716,6 +871,8 @@ export class RemoteMcpService {
716
  }
717
  return result;
718
  } finally {
 
 
719
  operationReservation?.release();
720
  this.store.releaseSession(session);
721
  }
@@ -732,7 +889,8 @@ export class RemoteMcpService {
732
 
733
  async closeDocument(
734
  input: { docHandle: string },
735
- principal: Principal = LOCAL_PRINCIPAL
 
736
  ): Promise<CloseDocumentResult> {
737
  return { closed: await this.store.deleteSession(input.docHandle, principal) };
738
  }
@@ -761,6 +919,53 @@ export class RemoteMcpService {
761
  return session;
762
  }
763
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
764
  private assertSessionActive(session: DocumentSession): void {
765
  if (!this.store.isSessionActive(session)) throw sessionNotFoundError();
766
  }
@@ -1240,6 +1445,11 @@ export class RemoteMcpService {
1240
 
1241
  private async refreshTargets(session: DocumentSession): Promise<void> {
1242
  session.targetIndex = buildRemoteTargetIndexFromSelections(selectRemoteTargetSelections(session.bytes));
 
 
 
 
 
1243
  }
1244
 
1245
  private publicTargetsForIndexes(session: DocumentSession, indexes: number[]): PublicTarget[] {
@@ -1575,6 +1785,27 @@ function summariesForOrdinals(
1575
  .map((summary, position) => ({ ...summary, index: ordinals[position]! }));
1576
  }
1577
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1578
  function publicTarget(summary: RevisionTargetSummary, structureVersion: number): PublicTarget {
1579
  const paragraphLocatorAmbiguous =
1580
  summary.kind === "paragraph" &&
 
26
  import { downloadDocumentUrl } from "#mcp-remote/download";
27
  import { deriveMaxConcurrentSessionsPerPrincipal } from "./config.js";
28
  import {
29
+ createDownloadBytes,
30
  AdmissionGuardError,
31
  randomToken,
32
  SessionStore,
 
38
  Clock,
39
  DocumentAnalysisSnapshot,
40
  DocumentSession,
41
+ EphemeralObjectClient,
42
+ EphemeralObjectReference,
43
+ EphemeralObjectRequestContext,
44
  PublicTarget,
45
  Principal,
46
  RemoteEdit,
 
56
  config: RemoteMcpConfig;
57
  store?: SessionStore;
58
  clock?: Clock;
59
+ ephemeralObjects?: EphemeralObjectClient;
60
  }
61
 
62
  export interface OpenDocumentResult {
 
178
  readonly store: SessionStore;
179
  private readonly adapter = new DirectXmlAdapter();
180
  private readonly clock: Clock;
181
+ private readonly objectOperationTails = new WeakMap<DocumentSession, Promise<void>>();
182
 
183
  private get inFlightSessionCount(): number {
184
  return this.store.getAccountingTotals().inFlightCount;
 
204
 
205
  async openDocument(
206
  input: { url: string },
207
+ principal: Principal = LOCAL_PRINCIPAL,
208
+ requestContext: EphemeralObjectRequestContext = {}
209
  ): Promise<OpenDocumentResult> {
210
  this.assertInputBytesAllowed();
211
  const reservation = this.store.reserveSession(
 
219
  allowInsecureLocalhost: this.options.config.allowInsecureLocalhostDownloads
220
  });
221
  reservation.narrow(bytes.byteLength);
222
+ return await this.createReservedSession(bytes, principal, reservation, requestContext);
223
  } finally {
224
  reservation.release();
225
  }
 
227
 
228
  async createSession(
229
  bytes: Buffer,
230
+ principal: Principal = LOCAL_PRINCIPAL,
231
+ requestContext: EphemeralObjectRequestContext = {}
232
  ): Promise<OpenDocumentResult> {
233
  this.assertInputBytesAllowed(bytes);
234
  const reservation = this.store.reserveSession(principal, bytes.byteLength, this.sessionLimits());
235
  try {
236
+ return await this.createReservedSession(bytes, principal, reservation, requestContext);
237
  } finally {
238
  reservation.release();
239
  }
 
242
  private async createReservedSession(
243
  bytes: Buffer,
244
  principal: Principal,
245
+ reservation: WorkReservation,
246
+ requestContext: EphemeralObjectRequestContext
247
  ): Promise<OpenDocumentResult> {
248
  this.assertInputBytesAllowed(bytes);
249
  const operationReservation = this.store.reserveOperation(principal, this.sessionLimits());
 
280
  if (!validation.valid) {
281
  throw new Error(validation.errors[0]?.message ?? "inspect failed");
282
  }
283
+ let currentObject: EphemeralObjectReference | undefined;
284
+ if (this.options.ephemeralObjects) {
285
+ const stored = await this.options.ephemeralObjects.storeAndDownload(
286
+ bytes,
287
+ requiredBearerToken(requestContext)
288
+ );
289
+ if (stored.bytes !== bytes) bytes.fill(0);
290
+ bytes = stored.bytes;
291
+ currentObject = stored.ref;
292
+ }
293
  const targetIndex = buildRemoteTargetIndexFromSelections(selectRemoteTargetSelections(bytes));
294
+ const cachedPublicTargets = publicTargetsForBytes(bytes, targetIndex, 1);
295
  const openedAt = this.clock.now();
296
+ const absoluteExpiresAt = openedAt + this.options.config.sessionTtlMs;
297
  const session: DocumentSession = {
298
  docHandle: randomToken(),
299
  ownerPrincipal: principal,
300
+ bytes: currentObject ? Buffer.alloc(0) : bytes,
301
+ ...(currentObject ? { currentObject } : {}),
302
  structureVersion: 1,
303
  editSeq: 0,
304
  targetIndex,
305
+ cachedPublicTargets,
306
  structuralEditCount: 0,
307
  rollbackEntries: new Map(),
308
  downloadArtifacts: new Map(),
309
  paragraphAnchors: injected.state,
310
  absoluteTtlMs: this.options.config.sessionTtlMs,
311
+ expiresAt: Math.min(absoluteExpiresAt, currentObject?.retentionExpiresAt ?? absoluteExpiresAt),
312
  idleTtlMs: this.options.config.sessionIdleTtlMs,
313
  idleExpiresAt: openedAt + this.options.config.sessionIdleTtlMs
314
  };
 
317
  } catch (error) {
318
  session.bytes.fill(0);
319
  throw error;
320
+ } finally {
321
+ if (currentObject) bytes.fill(0);
322
  }
323
  return {
324
  docHandle: session.docHandle,
 
364
 
365
  listTargets(
366
  input: { docHandle: string; kind?: RemoteTargetKind; fillableOnly?: boolean },
367
+ principal: Principal = LOCAL_PRINCIPAL,
368
+ _requestContext: EphemeralObjectRequestContext = {}
369
  ): PublicTarget[] {
370
  const session = this.requireSession(input.docHandle, principal);
371
+ if (session.currentObject) {
372
+ const cached = session.cachedPublicTargets ?? [];
373
+ return cached
374
+ .filter((target) => input.kind === undefined || target.kind === input.kind)
375
+ .filter((target) => !input.fillableOnly || target.fillable === true)
376
+ .map(copyPublicTarget);
377
+ }
378
  const ordinals = session.targetIndex
379
  .filter((entry) => input.kind === undefined || entry.kind === input.kind)
380
  .map((entry) => entry.ordinal);
 
391
 
392
  async analyzeDocument(
393
  input: { docHandle: string },
394
+ principal: Principal = LOCAL_PRINCIPAL,
395
+ requestContext: EphemeralObjectRequestContext = {}
396
  ): Promise<DocumentAnalysisSnapshot> {
397
  const session = this.requireSessionLease(input.docHandle, principal);
398
  let operationReservation: WorkReservation | undefined;
399
+ let releaseObjectBytes: (() => void) | undefined;
400
  try {
401
  operationReservation = this.store.reserveOperation(principal, this.sessionLimits());
402
+ releaseObjectBytes = await this.hydrateObjectSession(session, requestContext);
403
  const inspect = await this.adapter.inspect(session.bytes);
404
  this.assertSessionActive(session);
405
  if (!inspect.inspectSuccess) {
 
412
  )
413
  );
414
  } finally {
415
+ releaseObjectBytes?.();
416
  operationReservation?.release();
417
  this.store.releaseSession(session);
418
  }
 
420
 
421
  async applyEdits(
422
  input: ApplyEditsInput,
423
+ principal: Principal = LOCAL_PRINCIPAL,
424
+ requestContext: EphemeralObjectRequestContext = {}
425
  ): Promise<ApplyEditsResult> {
426
  const session = this.requireSessionLease(input.docHandle, principal);
427
  let operationReservation: WorkReservation | undefined;
428
  let unownedRollbackBytes: Buffer | undefined;
429
+ let releaseObjectBytes: (() => void) | undefined;
430
  try {
431
  operationReservation = this.store.reserveOperation(principal, this.sessionLimits());
432
+ releaseObjectBytes = await this.hydrateObjectSession(session, requestContext);
433
  if (input.edits.length > this.options.config.maxEditsPerBatch) {
434
  return {
435
  success: false,
 
597
  return { ...base, operation: "editEquationScript", latex: edit.value! };
598
  });
599
 
600
+ unownedRollbackBytes = structural && !session.currentObject ? Buffer.from(session.bytes) : undefined;
601
  const patch = await this.adapter.patch(session.bytes, operations, {
602
  paragraphAnchors: session.paragraphAnchors,
603
  retainPackageForExport: true
 
625
  : base;
626
  }
627
 
628
+ if (session.currentObject && this.options.ephemeralObjects) {
629
+ const previousObject = session.currentObject;
630
+ const bearerToken = requiredBearerToken(requestContext);
631
+ const exportBytes = createDownloadBytes(session, patch.output, patch.packageForExport);
632
+ let storedInternal;
633
+ let storedResult;
634
+ try {
635
+ storedInternal = await this.options.ephemeralObjects.storeAndDownload(patch.output, bearerToken);
636
+ storedResult = await this.options.ephemeralObjects.storeAndDownload(exportBytes, bearerToken);
637
+ } finally {
638
+ exportBytes.fill(0);
639
+ }
640
+ storedResult.bytes.fill(0);
641
+ if (storedInternal.bytes !== patch.output) patch.output.fill(0);
642
+ const nextBytes = storedInternal.bytes;
643
+ const nextStructureVersion = session.structureVersion + (structural ? 1 : 0);
644
+ const nextTargetIndex = buildRemoteTargetIndexFromSelections(selectRemoteTargetSelections(nextBytes));
645
+ const nextPublicTargets = publicTargetsForBytes(nextBytes, nextTargetIndex, nextStructureVersion);
646
+ const rollbackToken = randomToken();
647
+ this.store.addRollback(session, {
648
+ token: rollbackToken,
649
+ editSeq: session.editSeq + 1,
650
+ inverse: patch.inverse ?? [],
651
+ objectBefore: previousObject,
652
+ targetIndexes: resolved.edits.map((edit) => edit.index),
653
+ expiresAt: Math.min(session.expiresAt, previousObject.retentionExpiresAt)
654
+ }, this.options.config.maxRollbackEntriesPerSession);
655
+
656
+ replaceSessionBytes(session, nextBytes);
657
+ session.currentObject = storedInternal.ref;
658
+ session.editSeq += 1;
659
+ session.structuralEditCount += structuralEdits.length;
660
+ session.structureVersion = nextStructureVersion;
661
+ session.targetIndex = nextTargetIndex;
662
+ session.cachedPublicTargets = nextPublicTargets;
663
+
664
+ const result: ApplyEditsResult = {
665
+ ...base,
666
+ downloadUrl: storedResult.downloadUrl,
667
+ downloadExpiresAt: new Date(storedResult.downloadExpiresAt).toISOString(),
668
+ rollbackToken,
669
+ ...(resolved.warnings.length > 0 ? { warnings: resolved.warnings } : {})
670
+ };
671
+ if (structural) {
672
+ result.structureChanged = true;
673
+ result.structureVersion = session.structureVersion;
674
+ const addParagraphResult = patch.applied.find(
675
+ (operation) => operation.operation === "addParagraph"
676
+ );
677
+ if (addParagraphResult?.newParagraphIndex !== undefined) {
678
+ result.newParagraphIndex = addParagraphResult.newParagraphIndex;
679
+ }
680
+ } else {
681
+ result.updatedTargets = this.publicTargetsForIndexes(
682
+ session,
683
+ resolved.edits.map((edit) => edit.index)
684
+ );
685
+ }
686
+ return result;
687
+ }
688
+
689
  replaceSessionBytes(session, patch.output);
690
  session.editSeq += 1;
691
  session.structuralEditCount += structuralEdits.length;
 
737
  try {
738
  unownedRollbackBytes?.fill(0);
739
  } finally {
740
+ releaseObjectBytes?.();
741
  operationReservation?.release();
742
  this.store.releaseSession(session);
743
  }
 
746
 
747
  async rollback(
748
  input: { docHandle: string; rollbackToken: string },
749
+ principal: Principal = LOCAL_PRINCIPAL,
750
+ requestContext: EphemeralObjectRequestContext = {}
751
  ): Promise<RollbackResult> {
752
  const session = this.requireSessionLease(input.docHandle, principal);
753
  let operationReservation: WorkReservation | undefined;
754
+ let releaseObjectBytes: (() => void) | undefined;
755
+ let rollbackObjectBytes: Buffer | undefined;
756
  try {
757
  operationReservation = this.store.reserveOperation(principal, this.sessionLimits());
758
+ releaseObjectBytes = await this.hydrateObjectSession(session, requestContext);
759
  const rollback = this.store.getRollback(session, input.rollbackToken, principal);
760
  if (!rollback) {
761
  return { success: false, code: "rollback-not-found", message: "Rollback token was not found or has expired; call rollback with the rollbackToken returned by the edit you want to undo." };
 
776
  };
777
  }
778
 
779
+ if (rollback.objectBefore && this.options.ephemeralObjects) {
780
+ rollbackObjectBytes = await this.options.ephemeralObjects.download(
781
+ rollback.objectBefore,
782
+ requiredBearerToken(requestContext)
783
+ );
784
+ }
785
+ const rollbackBytes = rollbackObjectBytes ?? rollback.bytesBefore;
786
+ let rollbackPackage = rollbackBytes ? readHwpxPackage(rollbackBytes) : undefined;
787
  if (rollbackPackage) syncActiveParagraphAnchorsInPackage(rollbackPackage, session.paragraphAnchors);
788
+ const patch = rollbackBytes
789
+ ? { success: true as const, output: Buffer.from(rollbackBytes), applied: [], errors: [], packageForExport: rollbackPackage }
790
  : await this.adapter.patch(session.bytes, rollback.inverse, {
791
  paragraphAnchors: session.paragraphAnchors,
792
  retainPackageForExport: true
 
801
  return base;
802
  }
803
 
804
+ if (session.currentObject && this.options.ephemeralObjects) {
805
+ const bearerToken = requiredBearerToken(requestContext);
806
+ const exportBytes = createDownloadBytes(session, patch.output, patch.packageForExport);
807
+ let storedInternal;
808
+ let storedResult;
809
+ try {
810
+ storedInternal = await this.options.ephemeralObjects.storeAndDownload(patch.output, bearerToken);
811
+ storedResult = await this.options.ephemeralObjects.storeAndDownload(exportBytes, bearerToken);
812
+ } finally {
813
+ exportBytes.fill(0);
814
+ }
815
+ storedResult.bytes.fill(0);
816
+ if (storedInternal.bytes !== patch.output) patch.output.fill(0);
817
+ const nextBytes = storedInternal.bytes;
818
+ const structural = structuralEditCount > 0;
819
+ const nextStructureVersion = session.structureVersion + (structural ? 1 : 0);
820
+ const nextTargetIndex = buildRemoteTargetIndexFromSelections(selectRemoteTargetSelections(nextBytes));
821
+ const nextPublicTargets = publicTargetsForBytes(nextBytes, nextTargetIndex, nextStructureVersion);
822
+
823
+ this.store.deleteRollback(session, input.rollbackToken, principal);
824
+ replaceSessionBytes(session, nextBytes);
825
+ session.currentObject = storedInternal.ref;
826
+ session.editSeq += 1;
827
+ session.structuralEditCount += structuralEditCount;
828
+ session.structureVersion = nextStructureVersion;
829
+ session.targetIndex = nextTargetIndex;
830
+ session.cachedPublicTargets = nextPublicTargets;
831
+
832
+ const result: RollbackResult = {
833
+ ...base,
834
+ downloadUrl: storedResult.downloadUrl,
835
+ downloadExpiresAt: new Date(storedResult.downloadExpiresAt).toISOString()
836
+ };
837
+ if (structural) {
838
+ result.structureChanged = true;
839
+ result.structureVersion = session.structureVersion;
840
+ } else {
841
+ result.updatedTargets = this.publicTargetsForIndexes(session, rollback.targetIndexes);
842
+ }
843
+ return result;
844
+ }
845
+
846
  replaceSessionBytes(session, patch.output);
847
  session.editSeq += 1;
848
  session.structuralEditCount += structuralEditCount;
 
871
  }
872
  return result;
873
  } finally {
874
+ rollbackObjectBytes?.fill(0);
875
+ releaseObjectBytes?.();
876
  operationReservation?.release();
877
  this.store.releaseSession(session);
878
  }
 
889
 
890
  async closeDocument(
891
  input: { docHandle: string },
892
+ principal: Principal = LOCAL_PRINCIPAL,
893
+ _requestContext: EphemeralObjectRequestContext = {}
894
  ): Promise<CloseDocumentResult> {
895
  return { closed: await this.store.deleteSession(input.docHandle, principal) };
896
  }
 
919
  return session;
920
  }
921
 
922
+ private async hydrateObjectSession(
923
+ session: DocumentSession,
924
+ requestContext: EphemeralObjectRequestContext
925
+ ): Promise<(() => void) | undefined> {
926
+ if (!session.currentObject) return undefined;
927
+ if (!this.options.ephemeralObjects) throw new Error("Temporary object storage is unavailable.");
928
+ const previous = this.objectOperationTails.get(session) ?? Promise.resolve();
929
+ let unlock!: () => void;
930
+ const gate = new Promise<void>((resolve) => { unlock = resolve; });
931
+ const tail = previous.catch(() => undefined).then(() => gate);
932
+ this.objectOperationTails.set(session, tail);
933
+ await previous.catch(() => undefined);
934
+ if (!this.store.isSessionActive(session)) {
935
+ unlock();
936
+ throw sessionNotFoundError();
937
+ }
938
+ let hydrated: Buffer;
939
+ try {
940
+ hydrated = await this.options.ephemeralObjects.download(
941
+ session.currentObject,
942
+ requiredBearerToken(requestContext)
943
+ );
944
+ } catch (error) {
945
+ unlock();
946
+ if (this.objectOperationTails.get(session) === tail) this.objectOperationTails.delete(session);
947
+ throw error;
948
+ }
949
+ if (hydrated.byteLength !== session.currentObject.byteLength) {
950
+ hydrated.fill(0);
951
+ unlock();
952
+ if (this.objectOperationTails.get(session) === tail) this.objectOperationTails.delete(session);
953
+ throw new Error("Temporary object storage is unavailable.");
954
+ }
955
+ session.bytes.fill(0);
956
+ session.bytes = hydrated;
957
+ let released = false;
958
+ return () => {
959
+ if (released) return;
960
+ released = true;
961
+ session.bytes.fill(0);
962
+ session.bytes = Buffer.alloc(0);
963
+ if (hydrated !== session.bytes) hydrated.fill(0);
964
+ unlock();
965
+ if (this.objectOperationTails.get(session) === tail) this.objectOperationTails.delete(session);
966
+ };
967
+ }
968
+
969
  private assertSessionActive(session: DocumentSession): void {
970
  if (!this.store.isSessionActive(session)) throw sessionNotFoundError();
971
  }
 
1445
 
1446
  private async refreshTargets(session: DocumentSession): Promise<void> {
1447
  session.targetIndex = buildRemoteTargetIndexFromSelections(selectRemoteTargetSelections(session.bytes));
1448
+ session.cachedPublicTargets = publicTargetsForBytes(
1449
+ session.bytes,
1450
+ session.targetIndex,
1451
+ session.structureVersion
1452
+ );
1453
  }
1454
 
1455
  private publicTargetsForIndexes(session: DocumentSession, indexes: number[]): PublicTarget[] {
 
1785
  .map((summary, position) => ({ ...summary, index: ordinals[position]! }));
1786
  }
1787
 
1788
+ function publicTargetsForBytes(
1789
+ bytes: Buffer,
1790
+ targetIndex: readonly RemoteTargetIndexEntry[],
1791
+ structureVersion: number
1792
+ ): PublicTarget[] {
1793
+ const ordinals = targetIndex.map((entry) => entry.ordinal);
1794
+ return summariesForOrdinals(materializeRemoteTargets(bytes, ordinals), ordinals, bytes)
1795
+ .map((summary) => publicTarget(summary, structureVersion));
1796
+ }
1797
+
1798
+ function copyPublicTarget(target: PublicTarget): PublicTarget {
1799
+ return structuredClone(target);
1800
+ }
1801
+
1802
+ function requiredBearerToken(context: EphemeralObjectRequestContext): string {
1803
+ if (typeof context.bearerToken !== "string" || context.bearerToken.length === 0) {
1804
+ throw new Error("Temporary object access requires an authenticated user request.");
1805
+ }
1806
+ return context.bearerToken;
1807
+ }
1808
+
1809
  function publicTarget(summary: RevisionTargetSummary, structureVersion: number): PublicTarget {
1810
  const paragraphLocatorAmbiguous =
1811
  summary.kind === "paragraph" &&
packages/mcp-remote/src/stable-handle.ts ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { sha256Hex } from "@hwpxkit/core-ts/sha256";
2
+ import { Buffer } from "node:buffer";
3
+
4
+ const HANDLE_DOMAIN = "hwpxkit.stable-target-handle.v1";
5
+ const HANDLE_PREFIX = "sh1_";
6
+
7
+ /**
8
+ * A 128-bit truncation keeps handles compact while retaining a conservative
9
+ * birthday bound. The bound and the measured candidate population are recorded
10
+ * by the m414 report; this constant is the single implementation knob.
11
+ */
12
+ export const STABLE_HANDLE_TRUNCATED_BITS = 128;
13
+
14
+ export interface StableHandlePathSegment {
15
+ /** Structural element role, not document text (for example, "table" or "row"). */
16
+ readonly kind: string;
17
+ /** Zero-based ordinal among structural siblings of the same role. */
18
+ readonly ordinal: number;
19
+ }
20
+
21
+ declare const fullAncestorPathBrand: unique symbol;
22
+
23
+ /**
24
+ * Complete document-root-to-target structural path. It begins with document:0,
25
+ * then the source part, and callers must not omit any intermediate ancestor.
26
+ */
27
+ export type FullAncestorPath = readonly StableHandlePathSegment[] & {
28
+ readonly [fullAncestorPathBrand]: true;
29
+ };
30
+
31
+ export interface StableHandleCandidate<T> {
32
+ readonly fullAncestorPath: FullAncestorPath;
33
+ readonly value: T;
34
+ }
35
+
36
+ export type StableHandleResolutionErrorCode =
37
+ | "stable-handle-no-match"
38
+ | "stable-handle-multiple-matches";
39
+
40
+ export class StableHandleResolutionError extends Error {
41
+ readonly code: StableHandleResolutionErrorCode;
42
+
43
+ constructor(code: StableHandleResolutionErrorCode) {
44
+ super(
45
+ code === "stable-handle-no-match"
46
+ ? "Stable handle did not resolve to exactly one target."
47
+ : "Stable handle resolved to more than one target."
48
+ );
49
+ this.name = "StableHandleResolutionError";
50
+ this.code = code;
51
+ }
52
+ }
53
+
54
+ export interface StableHandleInput {
55
+ /** Exact document bytes supplied by the current request. */
56
+ readonly documentBytes: Uint8Array;
57
+ readonly structureVersion: number;
58
+ readonly fullAncestorPath: FullAncestorPath;
59
+ }
60
+
61
+ export interface ResolveStableHandleInput<T> {
62
+ /** Exact document bytes supplied by the current request. */
63
+ readonly documentBytes: Uint8Array;
64
+ readonly structureVersion: number;
65
+ readonly handle: string;
66
+ readonly candidates: readonly StableHandleCandidate<T>[];
67
+ }
68
+
69
+ /**
70
+ * Constructs a canonical root-to-target path. The opaque handle never exposes
71
+ * these segments; they are inputs only to unsigned digest recomputation.
72
+ */
73
+ export function createFullAncestorPath(
74
+ segments: readonly StableHandlePathSegment[]
75
+ ): FullAncestorPath {
76
+ if (
77
+ segments.length < 3 ||
78
+ segments[0]?.kind !== "document" ||
79
+ segments[0].ordinal !== 0 ||
80
+ segments[1]?.kind !== "part"
81
+ ) {
82
+ throw new TypeError(
83
+ "A full ancestor path must start with document:0 and a source part, then end at the target."
84
+ );
85
+ }
86
+
87
+ const copy = segments.map((segment) => {
88
+ if (!/^[A-Za-z][A-Za-z0-9._-]{0,63}$/u.test(segment.kind)) {
89
+ throw new TypeError("A path segment kind must use the canonical structural-token form.");
90
+ }
91
+ if (!Number.isSafeInteger(segment.ordinal) || segment.ordinal < 0) {
92
+ throw new TypeError("A path segment ordinal must be a non-negative safe integer.");
93
+ }
94
+ return Object.freeze({ kind: segment.kind, ordinal: segment.ordinal });
95
+ });
96
+
97
+ return Object.freeze(copy) as FullAncestorPath;
98
+ }
99
+
100
+ /**
101
+ * Computes an opaque, unsigned handle. There is deliberately no key or secret:
102
+ * the receiver resolves by recomputing candidates from the request bytes.
103
+ */
104
+ export function createStableTargetHandle(input: StableHandleInput): string {
105
+ validateStructureVersion(input.structureVersion);
106
+ const contentSha = hexToBytes(sha256Hex(input.documentBytes));
107
+ const preimage = encodeFrames([
108
+ utf8(HANDLE_DOMAIN),
109
+ contentSha,
110
+ utf8(input.structureVersion.toString(10)),
111
+ utf8(input.fullAncestorPath.length.toString(10)),
112
+ ...input.fullAncestorPath.flatMap((segment) => [
113
+ utf8(segment.kind),
114
+ utf8(segment.ordinal.toString(10))
115
+ ])
116
+ ]);
117
+ const digestHex = sha256Hex(preimage);
118
+ const truncated = hexToBytes(digestHex).subarray(0, STABLE_HANDLE_TRUNCATED_BITS / 8);
119
+ return `${HANDLE_PREFIX}${Buffer.from(truncated).toString("base64url")}`;
120
+ }
121
+
122
+ /**
123
+ * Recomputes every candidate and succeeds only for exactly one match. In
124
+ * particular, it never selects the first candidate when paths or truncated
125
+ * digests collide.
126
+ */
127
+ export function resolveStableTargetHandle<T>(input: ResolveStableHandleInput<T>): T {
128
+ validateStructureVersion(input.structureVersion);
129
+ const matches: T[] = [];
130
+
131
+ for (const candidate of input.candidates) {
132
+ const recomputed = createStableTargetHandle({
133
+ documentBytes: input.documentBytes,
134
+ structureVersion: input.structureVersion,
135
+ fullAncestorPath: candidate.fullAncestorPath
136
+ });
137
+ if (recomputed === input.handle) matches.push(candidate.value);
138
+ }
139
+
140
+ if (matches.length === 0) {
141
+ throw new StableHandleResolutionError("stable-handle-no-match");
142
+ }
143
+ if (matches.length !== 1) {
144
+ throw new StableHandleResolutionError("stable-handle-multiple-matches");
145
+ }
146
+ return matches[0] as T;
147
+ }
148
+
149
+ function validateStructureVersion(structureVersion: number): void {
150
+ if (!Number.isSafeInteger(structureVersion) || structureVersion <= 0) {
151
+ throw new TypeError("structureVersion must be a positive safe integer.");
152
+ }
153
+ }
154
+
155
+ function utf8(value: string): Uint8Array {
156
+ return new TextEncoder().encode(value);
157
+ }
158
+
159
+ function hexToBytes(value: string): Uint8Array {
160
+ const result = new Uint8Array(value.length / 2);
161
+ for (let index = 0; index < result.length; index += 1) {
162
+ result[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
163
+ }
164
+ return result;
165
+ }
166
+
167
+ /** Each field is independently length-framed, avoiding concatenation ambiguity. */
168
+ function encodeFrames(fields: readonly Uint8Array[]): Uint8Array {
169
+ const byteLength = fields.reduce((total, field) => total + 4 + field.byteLength, 0);
170
+ const output = new Uint8Array(byteLength);
171
+ const view = new DataView(output.buffer);
172
+ let offset = 0;
173
+ for (const field of fields) {
174
+ view.setUint32(offset, field.byteLength, false);
175
+ offset += 4;
176
+ output.set(field, offset);
177
+ offset += field.byteLength;
178
+ }
179
+ return output;
180
+ }
packages/mcp-remote/src/store.ts CHANGED
@@ -244,9 +244,7 @@ export class SessionStore {
244
  if (this.downloads.has(token) || session.downloadArtifacts.has(token)) {
245
  throw new Error("Download token collision; retry the edit to create a new download.");
246
  }
247
- const exportPackage = packageForExport ?? readHwpxPackage(bytes);
248
- stripParagraphAnchorsInPackage(exportPackage, session.paragraphAnchors);
249
- const exportBytes = writeHwpxPackage(exportPackage);
250
  if (session.downloadArtifacts.size >= maxArtifacts) {
251
  const oldestToken = session.downloadArtifacts.keys().next().value;
252
  if (oldestToken !== undefined) {
@@ -467,6 +465,16 @@ export function retainedBytesForSession(session: DocumentSession): number {
467
  return bytes;
468
  }
469
 
 
 
 
 
 
 
 
 
 
 
470
  function wipeDownloadArtifact(artifact: DownloadArtifact): void {
471
  artifact.bytes.fill(0);
472
  artifact.bytes = Buffer.alloc(0);
 
244
  if (this.downloads.has(token) || session.downloadArtifacts.has(token)) {
245
  throw new Error("Download token collision; retry the edit to create a new download.");
246
  }
247
+ const exportBytes = createDownloadBytes(session, bytes, packageForExport);
 
 
248
  if (session.downloadArtifacts.size >= maxArtifacts) {
249
  const oldestToken = session.downloadArtifacts.keys().next().value;
250
  if (oldestToken !== undefined) {
 
465
  return bytes;
466
  }
467
 
468
+ export function createDownloadBytes(
469
+ session: DocumentSession,
470
+ bytes: Buffer,
471
+ packageForExport?: HwpxZipPackage
472
+ ): Buffer {
473
+ const exportPackage = packageForExport ?? readHwpxPackage(bytes);
474
+ stripParagraphAnchorsInPackage(exportPackage, session.paragraphAnchors);
475
+ return writeHwpxPackage(exportPackage);
476
+ }
477
+
478
  function wipeDownloadArtifact(artifact: DownloadArtifact): void {
479
  artifact.bytes.fill(0);
480
  artifact.bytes = Buffer.alloc(0);
packages/mcp-remote/src/test-rate-limit-config.ts CHANGED
@@ -6,6 +6,7 @@ const resolvedConfig = configFromEnv({
6
  });
7
 
8
  export const TEST_RATE_LIMIT_CONFIG = Object.freeze({
 
9
  rateLimitWindowMs: resolvedConfig.rateLimitWindowMs,
10
  rateLimitMaxCalls: resolvedConfig.rateLimitMaxCalls
11
  });
 
6
  });
7
 
8
  export const TEST_RATE_LIMIT_CONFIG = Object.freeze({
9
+ requiredScopes: resolvedConfig.requiredScopes,
10
  rateLimitWindowMs: resolvedConfig.rateLimitWindowMs,
11
  rateLimitMaxCalls: resolvedConfig.rateLimitMaxCalls
12
  });
packages/mcp-remote/src/types.ts CHANGED
@@ -39,6 +39,40 @@ export interface Clock {
39
 
40
  export type Principal = string;
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  // Non-OAuth deployments intentionally collapse every authenticated request to
43
  // one principal while still exercising the same ownership checks.
44
  export const LOCAL_PRINCIPAL: Principal = "hwpxkit:local-principal";
@@ -56,6 +90,7 @@ export interface RollbackEntry {
56
  editSeq: number;
57
  inverse: DirectXmlPatchOperation[];
58
  bytesBefore?: Buffer;
 
59
  targetIndexes: number[];
60
  expiresAt: number;
61
  }
@@ -64,9 +99,11 @@ export interface DocumentSession {
64
  docHandle: string;
65
  ownerPrincipal: Principal;
66
  bytes: Buffer;
 
67
  structureVersion: number;
68
  editSeq: number;
69
  targetIndex: RemoteTargetIndexEntry[];
 
70
  structuralEditCount: number;
71
  rollbackEntries: Map<string, RollbackEntry>;
72
  downloadArtifacts: Map<string, DownloadArtifact>;
@@ -140,6 +177,7 @@ export interface PublicTarget {
140
  export interface RemoteMcpConfig {
141
  authToken: string;
142
  publicBaseUrl: string;
 
143
  sessionTtlMs: number;
144
  sessionIdleTtlMs: number;
145
  maxFileBytes: number;
@@ -158,6 +196,7 @@ export interface RemoteMcpConfig {
158
  supabaseUrl?: string;
159
  supabasePublishableKey?: string;
160
  supabaseJwtAudience?: string;
 
161
  allowInsecureLocalhostDownloads?: boolean;
162
  serviceVersion?: string;
163
  statusAllowedOrigin?: string;
@@ -168,6 +207,7 @@ export interface RemoteMcpEnv {
168
  MEMORY?: string;
169
  MCP_AUTH_TOKEN?: string;
170
  PUBLIC_BASE_URL?: string;
 
171
  OPENAI_APPS_CHALLENGE?: string;
172
  SESSION_TTL_MS?: string;
173
  SESSION_IDLE_TTL_MS?: string;
@@ -186,6 +226,7 @@ export interface RemoteMcpEnv {
186
  SUPABASE_URL?: string;
187
  SUPABASE_PUBLISHABLE_KEY?: string;
188
  SUPABASE_JWT_AUDIENCE?: string;
 
189
  SERVICE_VERSION?: string;
190
  STATUS_ALLOWED_ORIGIN?: string;
191
  }
 
39
 
40
  export type Principal = string;
41
 
42
+ export interface AuthContext {
43
+ clientId: unknown | undefined;
44
+ scope: unknown | undefined;
45
+ scopes: ReadonlySet<string> | undefined;
46
+ }
47
+
48
+ export interface AuthenticatedPrincipal {
49
+ principal: Principal;
50
+ authContext: AuthContext;
51
+ bearerToken?: string;
52
+ }
53
+
54
+ export type EphemeralObjectRequestContext = Readonly<{
55
+ bearerToken?: string;
56
+ }>;
57
+
58
+ export type EphemeralObjectReference = Readonly<{
59
+ objectId: string;
60
+ byteLength: number;
61
+ retentionExpiresAt: number;
62
+ }>;
63
+
64
+ export type StoredEphemeralObject = Readonly<{
65
+ ref: EphemeralObjectReference;
66
+ downloadUrl: string;
67
+ downloadExpiresAt: number;
68
+ bytes: Buffer;
69
+ }>;
70
+
71
+ export interface EphemeralObjectClient {
72
+ storeAndDownload(bytes: Buffer, bearerToken: string): Promise<StoredEphemeralObject>;
73
+ download(ref: EphemeralObjectReference, bearerToken: string): Promise<Buffer>;
74
+ }
75
+
76
  // Non-OAuth deployments intentionally collapse every authenticated request to
77
  // one principal while still exercising the same ownership checks.
78
  export const LOCAL_PRINCIPAL: Principal = "hwpxkit:local-principal";
 
90
  editSeq: number;
91
  inverse: DirectXmlPatchOperation[];
92
  bytesBefore?: Buffer;
93
+ objectBefore?: EphemeralObjectReference;
94
  targetIndexes: number[];
95
  expiresAt: number;
96
  }
 
99
  docHandle: string;
100
  ownerPrincipal: Principal;
101
  bytes: Buffer;
102
+ currentObject?: EphemeralObjectReference;
103
  structureVersion: number;
104
  editSeq: number;
105
  targetIndex: RemoteTargetIndexEntry[];
106
+ cachedPublicTargets?: PublicTarget[];
107
  structuralEditCount: number;
108
  rollbackEntries: Map<string, RollbackEntry>;
109
  downloadArtifacts: Map<string, DownloadArtifact>;
 
177
  export interface RemoteMcpConfig {
178
  authToken: string;
179
  publicBaseUrl: string;
180
+ requiredScopes: readonly string[];
181
  sessionTtlMs: number;
182
  sessionIdleTtlMs: number;
183
  maxFileBytes: number;
 
196
  supabaseUrl?: string;
197
  supabasePublishableKey?: string;
198
  supabaseJwtAudience?: string;
199
+ webappEphemeralObjectBaseUrl?: string;
200
  allowInsecureLocalhostDownloads?: boolean;
201
  serviceVersion?: string;
202
  statusAllowedOrigin?: string;
 
207
  MEMORY?: string;
208
  MCP_AUTH_TOKEN?: string;
209
  PUBLIC_BASE_URL?: string;
210
+ MCP_REQUIRED_SCOPES?: string;
211
  OPENAI_APPS_CHALLENGE?: string;
212
  SESSION_TTL_MS?: string;
213
  SESSION_IDLE_TTL_MS?: string;
 
226
  SUPABASE_URL?: string;
227
  SUPABASE_PUBLISHABLE_KEY?: string;
228
  SUPABASE_JWT_AUDIENCE?: string;
229
+ WEBAPP_EPHEMERAL_OBJECT_BASE_URL?: string;
230
  SERVICE_VERSION?: string;
231
  STATUS_ALLOWED_ORIGIN?: string;
232
  }