Spaces:
Running
Running
Deploy ephemeral-object issuer wiring (session 210)
Browse files- Dockerfile +1 -1
- README.md +5 -3
- packages/mcp-remote/src/config.ts +35 -3
- packages/mcp-remote/src/ephemeral-object-client.ts +216 -0
- packages/mcp-remote/src/http.ts +111 -38
- packages/mcp-remote/src/index.ts +20 -1
- packages/mcp-remote/src/mcp.ts +37 -12
- packages/mcp-remote/src/period-rate-limit-transport.ts +33 -2
- packages/mcp-remote/src/scope-aware-http-transport.ts +122 -0
- packages/mcp-remote/src/scope-enforcement.ts +18 -0
- packages/mcp-remote/src/service.ts +247 -16
- packages/mcp-remote/src/stable-handle.ts +180 -0
- packages/mcp-remote/src/store.ts +11 -3
- packages/mcp-remote/src/test-rate-limit-config.ts +1 -0
- packages/mcp-remote/src/types.ts +41 -0
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=
|
| 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 |
-
-
|
| 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 `
|
| 25 |
-
- `
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 67 |
-
if (!
|
| 68 |
-
handleDownload(options.service, req, res,
|
| 69 |
return;
|
| 70 |
}
|
| 71 |
if (req.method === "POST" && (req.url === "/mcp" || req.url === "/mcp-claude")) {
|
| 72 |
const channel = req.url;
|
| 73 |
-
const
|
| 74 |
-
if (!
|
| 75 |
-
await handleMcpRequest(options.service, req, res,
|
| 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 |
-
|
|
|
|
| 197 |
): void {
|
| 198 |
-
|
|
|
|
| 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",
|
| 213 |
-
res.off("close",
|
| 214 |
res.off("error", abort);
|
| 215 |
};
|
| 216 |
-
const
|
| 217 |
if (settled) return;
|
| 218 |
settled = true;
|
| 219 |
cleanup();
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
};
|
| 223 |
const abort = (): void => {
|
| 224 |
-
|
| 225 |
-
settled = true;
|
| 226 |
-
cleanup();
|
| 227 |
-
reservation.abort();
|
| 228 |
};
|
| 229 |
-
res.once("finish",
|
| 230 |
-
res.once("close",
|
| 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 |
-
|
|
|
|
|
|
|
| 247 |
}
|
| 248 |
}
|
| 249 |
|
|
@@ -252,7 +272,7 @@ async function authenticate(
|
|
| 252 |
res: ServerResponse,
|
| 253 |
config: RemoteMcpConfig,
|
| 254 |
jwks: JWTVerifyGetKey | undefined
|
| 255 |
-
): Promise<
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 364 |
-
channel: Exclude<AuditChannel, "/download">
|
|
|
|
| 365 |
): Promise<void> {
|
|
|
|
| 366 |
req.headers.accept = req.headers.accept ?? "application/json, text/event-stream";
|
| 367 |
-
const
|
| 368 |
-
|
| 369 |
token: req.headers.authorization!.slice("Bearer ".length),
|
| 370 |
-
clientId
|
| 371 |
-
scopes: [],
|
| 372 |
-
extra: {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
};
|
| 374 |
-
const mcp = createMcpServer(service, principal
|
| 375 |
-
|
| 376 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
await mcp.connect(rateLimitedTransport);
|
| 378 |
res.on("close", () => {
|
| 379 |
void rateLimitedTransport.close();
|
| 380 |
void mcp.close();
|
| 381 |
});
|
| 382 |
-
await transport.handleRequest(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: "
|
| 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: "
|
| 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: "
|
| 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:
|
| 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:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 674 |
if (rollbackPackage) syncActiveParagraphAnchorsInPackage(rollbackPackage, session.paragraphAnchors);
|
| 675 |
-
const patch =
|
| 676 |
-
? { success: true as const, output: Buffer.from(
|
| 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
|
| 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 |
}
|