@moretea-labs/forge 1.5.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/DESIGN.md +7 -2
- package/README.md +4 -4
- package/README.zh-CN.md +2 -2
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/assets/hooks/pre-edit-guard.sh +8 -39
- package/assets/hooks/prompt-guard.sh +2 -2
- package/assets/plugin-registry.v1.json +54 -3
- package/assets/reference-configs/hook-operations.md +1 -1
- package/assets/reference-configs/sprint-contracts.md +2 -2
- package/assets/skill-version.json +2 -2
- package/assets/templates/helpers/ensure-task-workflow.sh +2 -2
- package/assets/templates/helpers/sprint-backlog.sh +6 -6
- package/docs/architecture/current/automation-and-schedule-engine.md +9 -3
- package/docs/architecture/current/codegraph-context-provider.md +15 -3
- package/docs/architecture/current/dispatch-and-agent-strategy.md +32 -13
- package/docs/architecture/current/entity-model.md +20 -0
- package/docs/architecture/current/external-plugin-governance.md +3 -3
- package/docs/architecture/current/system-overview.md +26 -1
- package/docs/architecture/current/thin-harness-v1.md +10 -0
- package/docs/operations/chatgpt-personal-assistant-runtime.md +12 -2
- package/docs/operations/controller-browser-plugin.md +12 -16
- package/docs/operations/features.md +1 -1
- package/docs/operations/homebrew.md +2 -2
- package/docs/tutorials/01-install-and-start.md +1 -1
- package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
- package/docs/wiki/Installation.md +1 -1
- package/docs/wiki/Quick-Start.md +1 -1
- package/package.json +1 -1
- package/scripts/check-public-docs.sh +14 -1
- package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
- package/scripts/sprint-backlog.sh +1 -1
- package/src/cli/commands/chatgpt.ts +31 -2
- package/src/cli/commands/plugin.ts +36 -9
- package/src/cli/controller/context-pack.ts +158 -6
- package/src/cli/hook/prompt-guard-decision.ts +17 -18
- package/src/cli/hook/prompt-intents.ts +1 -1
- package/src/cli/local-bridge/console-automations.ts +205 -23
- package/src/cli/local-bridge/console-view-models.ts +2 -0
- package/src/cli/local-bridge/facade-api.ts +2 -0
- package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
- package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
- package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
- package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
- package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
- package/src/cli/local-bridge/frontend/src/styles.css +27 -11
- package/src/cli/local-bridge/frontend/src/types.ts +6 -2
- package/src/cli/local-bridge/server.ts +29 -19
- package/src/cli/local-bridge/ui-dist/app.css +1 -1
- package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
- package/src/cli/mcp/paths.ts +8 -2
- package/src/cli/mcp/repository-tools.ts +124 -9
- package/src/cli/mcp/server.ts +164 -19
- package/src/cli/mcp/transports/http.ts +25 -2
- package/src/cli/mcp/transports/session-registry.ts +24 -0
- package/src/cli/repositories/command-classifier.ts +113 -0
- package/src/cli/repositories/safe-patch.ts +6 -4
- package/src/cli/repositories/structured-git.ts +49 -7
- package/src/cli/repository/inspector.ts +16 -3
- package/src/runtime/assistant/intent.ts +3 -3
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
- package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
- package/src/runtime/control-plane/execution/validation.ts +15 -2
- package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
- package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
- package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
- package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
- package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
- package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
- package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
- package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
- package/src/runtime/control-plane/facade/types.ts +33 -6
- package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
- package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
- package/src/runtime/diagnostics/mcp-timing.ts +6 -0
- package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
- package/src/runtime/execution/process-runtime/check-result.ts +10 -2
- package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
- package/src/runtime/execution/process-runtime/gc.ts +64 -10
- package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
- package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
- package/src/runtime/execution/process-runtime/runtime.ts +16 -1
- package/src/runtime/execution/process-runtime/store.ts +6 -0
- package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
- package/src/runtime/gateway/mcp/router.ts +1 -1
- package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
- package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
- package/src/runtime/plugins/browser-adapter.ts +19 -72
- package/src/runtime/plugins/browser-handoff-host.ts +6 -31
- package/src/runtime/plugins/browser-handoff.ts +0 -3
- package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
- package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
- package/src/runtime/plugins/external-registration.ts +84 -5
- package/src/runtime/plugins/first-party-registry.ts +2 -2
- package/src/runtime/plugins/gmail-adapter.ts +28 -2
- package/src/runtime/plugins/local-system-adapter.ts +2 -2
- package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
- package/src/runtime/plugins/store.ts +107 -5
- package/src/runtime/plugins/types.ts +4 -0
- package/src/runtime/recovery/actions.ts +0 -10
- package/src/runtime/recovery/classifier.ts +0 -9
- package/src/runtime/recovery/maintenance-executor.ts +43 -11
- package/src/runtime/recovery/probe.ts +0 -1
- package/src/runtime/recovery/types.ts +0 -1
- package/src/runtime/root/release-materialize.ts +143 -19
- package/src/runtime/root/service.ts +6 -4
- package/src/runtime/safe-tooling/index.ts +0 -1
- package/src/runtime/safe-tooling/types.ts +0 -30
- package/src/runtime/standalone-recovery/core.ts +2 -2
- package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
- package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
- package/src/runtime/workflow/schedules/engine.ts +357 -64
- package/src/runtime/workflow/schedules/types.ts +5 -0
- package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
- package/bin/forge-desktop-helper.mjs +0 -204
- package/docs/operations/controller-desktop-plugin.md +0 -112
- package/src/runtime/plugins/desktop-adapter.ts +0 -374
- package/src/runtime/safe-tooling/browser-safe.ts +0 -133
package/src/cli/mcp/paths.ts
CHANGED
|
@@ -93,7 +93,13 @@ function nearestExistingPath(path: string): string | undefined {
|
|
|
93
93
|
return current;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
export function resolveMcpPath(
|
|
96
|
+
export function resolveMcpPath(
|
|
97
|
+
repoRoot: string,
|
|
98
|
+
inputPath: string,
|
|
99
|
+
policy: McpPolicy,
|
|
100
|
+
intent: McpPathIntent,
|
|
101
|
+
canonicalRepoRoot?: string,
|
|
102
|
+
): McpPathDecision {
|
|
97
103
|
const normalized = normalizeMcpRelativePath(inputPath);
|
|
98
104
|
if (!normalized.ok || !normalized.relativePath) return normalized;
|
|
99
105
|
|
|
@@ -107,7 +113,7 @@ export function resolveMcpPath(repoRoot: string, inputPath: string, policy: McpP
|
|
|
107
113
|
return { ok: false, relativePath, reason: `path is not allowed for ${intent}: ${relativePath}` };
|
|
108
114
|
}
|
|
109
115
|
|
|
110
|
-
const repoRealpath = realpathSync(repoRoot);
|
|
116
|
+
const repoRealpath = canonicalRepoRoot ?? realpathSync(repoRoot);
|
|
111
117
|
const absolutePath = resolve(repoRealpath, relativePath);
|
|
112
118
|
if (intent === 'read' && !existsSync(absolutePath)) {
|
|
113
119
|
return { ok: false, relativePath, reason: `path does not exist: ${relativePath}` };
|
|
@@ -2,6 +2,10 @@ import { bindRepositoryEntities } from '../repositories/entity-migration';
|
|
|
2
2
|
import { bootstrapLocalProject, diagnoseLatestLocalProjectSource } from '../repositories/local-project-onboarding';
|
|
3
3
|
import { resolveEphemeralWorkspaceTarget } from '../repositories/ephemeral-workspace';
|
|
4
4
|
import type { ResolvedExecutionIdentity } from '../../runtime/control-plane/execution/execution-identity';
|
|
5
|
+
import { readExecutionSession } from '../../runtime/control-plane/execution/session-store';
|
|
6
|
+
import { getWorkContract } from '../../runtime/control-plane/facade/work-contract-store';
|
|
7
|
+
import { getControllerSession, listControllerSessions } from '../../runtime/control-plane/facade/controller-session-store';
|
|
8
|
+
import { isTerminalWorkContractStatus } from '../../runtime/control-plane/facade/types';
|
|
5
9
|
import { executeRepositoryCommand, previewRepositoryCommandExecution } from '../repositories/command-executor';
|
|
6
10
|
import { withControllerLock } from '../repositories/locks';
|
|
7
11
|
import {
|
|
@@ -9,6 +13,7 @@ import {
|
|
|
9
13
|
findIdenticalRepositoryRegistration,
|
|
10
14
|
getRepository,
|
|
11
15
|
listRepositories,
|
|
16
|
+
reconcileRepositoryCheckouts,
|
|
12
17
|
refreshRepository,
|
|
13
18
|
registerRepository,
|
|
14
19
|
removeRepository,
|
|
@@ -19,7 +24,7 @@ import {
|
|
|
19
24
|
} from '../repositories/registry';
|
|
20
25
|
import { buildControllerWorkbench } from '../repositories/workbench';
|
|
21
26
|
import { applySafePatch, buildSafePatchPlan } from '../repositories/safe-patch';
|
|
22
|
-
import { getEditSessionDiff } from '../editing/edit-session';
|
|
27
|
+
import { getEditSessionDiff, type EditSessionBinding } from '../editing/edit-session';
|
|
23
28
|
import { buildSyncOperationDigest, classifyUserFacingError } from '../../runtime/control-plane/facade/operation-digest';
|
|
24
29
|
import { diagnoseRepositoryStuckState, listRepositoryGoalRuns, readRepositoryGoalRegistry, runRepositoryGoal, upsertRepositoryGoal } from '../repositories/goal-registry';
|
|
25
30
|
import {
|
|
@@ -49,6 +54,7 @@ import {
|
|
|
49
54
|
executeRepositoryCommandViaProcessRuntime,
|
|
50
55
|
} from '../../runtime/execution/process-runtime/command-facade';
|
|
51
56
|
import { assessWorkMode, parseExplicitTaskMode } from '../controller/work-mode';
|
|
57
|
+
import { normalizeRepositoryCommand } from '../repositories/command-normalization';
|
|
52
58
|
import type { CallToolResult, McpToolDefinition } from './tools';
|
|
53
59
|
import {
|
|
54
60
|
boundUtf8,
|
|
@@ -240,6 +246,7 @@ export const repositoryToolDefinitions: McpToolDefinition[] = [
|
|
|
240
246
|
definition('repository_safe_patch_apply', 'Apply one coherent deterministic edit batch and return bounded review evidence. Checks are opt-in: pass check_ids only when the batch is stable. Long checks return managed Process handles instead of blocking the MCP call; use validation_only with the returned session/request ids to join later without replaying the patch.', {
|
|
241
247
|
repo_id: repoId,
|
|
242
248
|
checkout_id: { type: 'string', description: 'Optional checkout identity for repositories with multiple local clones.' },
|
|
249
|
+
work_id: { type: 'string', description: 'Optional durable Work identity. Workflow controllers should pass the exact claimed Work id so attribution survives transient MCP transport sessions.' },
|
|
243
250
|
session_id: { type: 'string', description: 'Existing edit session id. Required for validation_only; otherwise omit to create one.' },
|
|
244
251
|
purpose: { type: 'string', description: 'Purpose for a newly created edit session.' },
|
|
245
252
|
operations: { type: 'array', items: { type: 'object' }, description: 'Edit operations using the same shape as apply_patch. Required unless validation_only=true.' },
|
|
@@ -266,6 +273,7 @@ export const repositoryToolDefinitions: McpToolDefinition[] = [
|
|
|
266
273
|
definition('repository_command_execute', 'Execute one repository-scoped local command through Full Access, Goal delegation, or a resumable approval request. Legacy preview-token callers remain compatible.', {
|
|
267
274
|
repo_id: repoId,
|
|
268
275
|
checkout_id: { type: 'string', description: 'Optional checkout identity for repositories with multiple local clones.' },
|
|
276
|
+
work_id: { type: 'string', description: 'Optional durable Work identity. Workflow controllers should pass the exact claimed Work id so attribution survives transient MCP transport sessions.' },
|
|
269
277
|
command: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Legacy shell string or typed argv array. Typed argv executes without a shell.' },
|
|
270
278
|
cwd: { type: 'string', description: 'Optional root-relative working directory.' },
|
|
271
279
|
workspace_root: { type: 'string', description: 'Absolute existing local directory used as an ephemeral execution root. Mutually exclusive with repo_id/checkout_id; does not register or initialize the directory.' },
|
|
@@ -302,10 +310,89 @@ function withResponseMeta(payload: Record<string, unknown>, startedAt: number):
|
|
|
302
310
|
return response;
|
|
303
311
|
}
|
|
304
312
|
|
|
313
|
+
export interface RepositoryToolCallerContext {
|
|
314
|
+
sessionId?: string;
|
|
315
|
+
principalId?: string;
|
|
316
|
+
controllerInstanceId?: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function claimedSessionWorkId(
|
|
320
|
+
controllerHome: string,
|
|
321
|
+
repository: ReturnType<typeof resolveRepositorySelection>,
|
|
322
|
+
caller?: RepositoryToolCallerContext,
|
|
323
|
+
explicitWorkId?: unknown,
|
|
324
|
+
): string | undefined {
|
|
325
|
+
if (!caller?.principalId?.trim()) return undefined;
|
|
326
|
+
const requestedWorkId = typeof explicitWorkId === 'string' ? explicitWorkId.trim() : '';
|
|
327
|
+
if (requestedWorkId) {
|
|
328
|
+
const work = getWorkContract({ controllerHome, repoId: repository.repoId }, requestedWorkId);
|
|
329
|
+
if (!work || isTerminalWorkContractStatus(work.status)) throw new Error(`WORK_ATTRIBUTION_INVALID: ${requestedWorkId}`);
|
|
330
|
+
if (work.checkoutId && work.checkoutId !== repository.activeCheckoutId) throw new Error(`WORK_CHECKOUT_MISMATCH: ${requestedWorkId}:${repository.activeCheckoutId}`);
|
|
331
|
+
const owner = getControllerSession({ controllerHome, repoId: repository.repoId }, requestedWorkId);
|
|
332
|
+
if (!owner) throw new Error(`WORK_CONTROLLER_CLAIM_REQUIRED: ${requestedWorkId}`);
|
|
333
|
+
if ((owner.principalId?.trim() || owner.controllerId) !== caller.principalId.trim()) throw new Error(`WORK_CONTROLLER_OWNERSHIP_MISMATCH: ${requestedWorkId}`);
|
|
334
|
+
return requestedWorkId;
|
|
335
|
+
}
|
|
336
|
+
if (caller.sessionId?.trim()) {
|
|
337
|
+
const executionSession = readExecutionSession(controllerHome, {
|
|
338
|
+
sessionId: caller.sessionId,
|
|
339
|
+
principalId: caller.principalId,
|
|
340
|
+
controllerInstanceId: caller.controllerInstanceId,
|
|
341
|
+
});
|
|
342
|
+
const workId = executionSession?.activeWorkId?.trim();
|
|
343
|
+
if (workId && executionSession?.activeRepositoryId === repository.repoId && (!executionSession.activeCheckoutId || executionSession.activeCheckoutId === repository.activeCheckoutId)) {
|
|
344
|
+
const work = getWorkContract({ controllerHome, repoId: repository.repoId }, workId);
|
|
345
|
+
const owner = getControllerSession({ controllerHome, repoId: repository.repoId }, workId);
|
|
346
|
+
if (work && !isTerminalWorkContractStatus(work.status) && owner?.sessionId === caller.sessionId && (owner.principalId?.trim() || owner.controllerId) === caller.principalId.trim()) return workId;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const principal = caller.principalId.trim();
|
|
350
|
+
const candidates = listControllerSessions({ controllerHome, repoId: repository.repoId })
|
|
351
|
+
.filter((owner) => (owner.principalId?.trim() || owner.controllerId) === principal)
|
|
352
|
+
.map((owner) => ({ owner, work: getWorkContract({ controllerHome, repoId: repository.repoId }, owner.workId) }))
|
|
353
|
+
.filter((entry): entry is { owner: ReturnType<typeof listControllerSessions>[number]; work: NonNullable<ReturnType<typeof getWorkContract>> } => Boolean(entry.work && !isTerminalWorkContractStatus(entry.work.status) && (!entry.work.checkoutId || entry.work.checkoutId === repository.activeCheckoutId)));
|
|
354
|
+
if (candidates.length === 1) return candidates[0].work.workId;
|
|
355
|
+
if (candidates.length > 1) throw new Error(`WORK_ATTRIBUTION_AMBIGUOUS: principal ${principal} owns ${candidates.length} active Works on checkout ${repository.activeCheckoutId}`);
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function claimedSessionEditBinding(
|
|
360
|
+
controllerHome: string,
|
|
361
|
+
repository: ReturnType<typeof resolveRepositorySelection>,
|
|
362
|
+
caller?: RepositoryToolCallerContext,
|
|
363
|
+
explicitWorkId?: unknown,
|
|
364
|
+
): EditSessionBinding | undefined {
|
|
365
|
+
const workId = claimedSessionWorkId(controllerHome, repository, caller, explicitWorkId);
|
|
366
|
+
if (!workId || !caller?.principalId?.trim()) return undefined;
|
|
367
|
+
const work = getWorkContract({ controllerHome, repoId: repository.repoId }, workId);
|
|
368
|
+
if (!work) return undefined;
|
|
369
|
+
return {
|
|
370
|
+
workId,
|
|
371
|
+
repoId: repository.repoId,
|
|
372
|
+
checkoutId: repository.activeCheckoutId,
|
|
373
|
+
principalId: caller.principalId.trim(),
|
|
374
|
+
controllerInstanceId: caller.controllerInstanceId,
|
|
375
|
+
routeDecisionFingerprint: work.routeDecisionFingerprint,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function activeWorkflowRawDefaultMergeBlocked(repository: ReturnType<typeof resolveRepositorySelection>, workId: string | undefined, command: unknown): boolean {
|
|
380
|
+
if (!workId || !repository.defaultBranch) return false;
|
|
381
|
+
const checkout = repository.checkouts.find((entry) => entry.checkoutId === repository.activeCheckoutId);
|
|
382
|
+
if (checkout?.branch !== repository.defaultBranch) return false;
|
|
383
|
+
const normalized = normalizeRepositoryCommand(command);
|
|
384
|
+
if (normalized.kind === 'argv') {
|
|
385
|
+
const executable = normalized.executable?.split('/').pop();
|
|
386
|
+
return executable === 'git' && normalized.args?.[0] === 'merge';
|
|
387
|
+
}
|
|
388
|
+
return /(?:^|[;&|]\s*)git\s+merge\b/.test(normalized.shellCommand ?? '');
|
|
389
|
+
}
|
|
390
|
+
|
|
305
391
|
function resolveRepositoryCommandTarget(
|
|
306
392
|
controllerHome: string,
|
|
307
393
|
args: Record<string, unknown>,
|
|
308
394
|
repoIdValue: string,
|
|
395
|
+
caller?: RepositoryToolCallerContext,
|
|
309
396
|
): {
|
|
310
397
|
repository: ReturnType<typeof resolveRepositorySelection>;
|
|
311
398
|
executionIdentity: ResolvedExecutionIdentity;
|
|
@@ -336,6 +423,7 @@ function resolveRepositoryCommandTarget(
|
|
|
336
423
|
controllerHome,
|
|
337
424
|
allowSoleRepository: true,
|
|
338
425
|
});
|
|
426
|
+
const workId = claimedSessionWorkId(controllerHome, repository, caller, args.work_id);
|
|
339
427
|
return {
|
|
340
428
|
repository,
|
|
341
429
|
executionIdentity: {
|
|
@@ -344,6 +432,7 @@ function resolveRepositoryCommandTarget(
|
|
|
344
432
|
repositoryId: repository.repoId,
|
|
345
433
|
checkoutId: repository.activeCheckoutId,
|
|
346
434
|
canonicalRoot: repository.canonicalRoot,
|
|
435
|
+
...(workId ? { workId } : {}),
|
|
347
436
|
},
|
|
348
437
|
};
|
|
349
438
|
}
|
|
@@ -551,6 +640,18 @@ function emptyRepositoryMigration(repository: ReturnType<typeof registerReposito
|
|
|
551
640
|
};
|
|
552
641
|
}
|
|
553
642
|
|
|
643
|
+
function registrationOnlyMetadataRefresh(
|
|
644
|
+
match: ReturnType<typeof findIdenticalRepositoryRegistration>,
|
|
645
|
+
): boolean {
|
|
646
|
+
if (!match || match.identical || match.reasons.length === 0) return false;
|
|
647
|
+
const metadataReasons = new Set([
|
|
648
|
+
'remote_identity_changed',
|
|
649
|
+
'default_branch_changed',
|
|
650
|
+
'display_name_changed',
|
|
651
|
+
]);
|
|
652
|
+
return match.reasons.every((reason) => metadataReasons.has(reason));
|
|
653
|
+
}
|
|
654
|
+
|
|
554
655
|
function registrationOnlyAttachedExistingWorktree(repository: ReturnType<typeof registerRepository>): boolean {
|
|
555
656
|
const selected = repository.checkouts.find((checkout) => checkout.checkoutId === repository.activeCheckoutId);
|
|
556
657
|
return selected?.worktree === true
|
|
@@ -561,6 +662,7 @@ export async function callRepositoryTool(
|
|
|
561
662
|
controllerHome: string,
|
|
562
663
|
name: string,
|
|
563
664
|
args: Record<string, unknown>,
|
|
665
|
+
caller?: RepositoryToolCallerContext,
|
|
564
666
|
): Promise<RepositoryToolResult | undefined> {
|
|
565
667
|
if (!name.startsWith('repository_')) return undefined;
|
|
566
668
|
try {
|
|
@@ -587,16 +689,17 @@ export async function callRepositoryTool(
|
|
|
587
689
|
startedAt,
|
|
588
690
|
));
|
|
589
691
|
}
|
|
692
|
+
const metadataRefresh = registrationOnlyMetadataRefresh(identical);
|
|
590
693
|
const repository = registerRepository(registerInput);
|
|
591
|
-
// Adding a worktree checkout
|
|
592
|
-
// checkout
|
|
593
|
-
// bound to
|
|
594
|
-
//
|
|
595
|
-
const migration = registrationOnlyAttachedExistingWorktree(repository)
|
|
694
|
+
// Adding a worktree checkout or changing only display/remote/default-branch
|
|
695
|
+
// metadata preserves repository + checkout authority. Existing entities
|
|
696
|
+
// are already bound to the stable repoId, so a historical migration scan
|
|
697
|
+
// would only add latency and stale unresolved diagnostics.
|
|
698
|
+
const migration = registrationOnlyAttachedExistingWorktree(repository) || metadataRefresh
|
|
596
699
|
? emptyRepositoryMigration(repository)
|
|
597
700
|
: bindRepositoryEntities(repository);
|
|
598
701
|
return result(withResponseMeta(
|
|
599
|
-
summarizeRepositoryRegistration(repository, migration, args.detail_level === 'detail'),
|
|
702
|
+
summarizeRepositoryRegistration(repository, migration, args.detail_level === 'detail', metadataRefresh),
|
|
600
703
|
startedAt,
|
|
601
704
|
));
|
|
602
705
|
}
|
|
@@ -623,6 +726,11 @@ export async function callRepositoryTool(
|
|
|
623
726
|
case 'repository_list':
|
|
624
727
|
return result({ repositories: listRepositories(controllerHome, { includeRemoved: args.include_removed === true }).map(repositorySummary) });
|
|
625
728
|
case 'repository_get': {
|
|
729
|
+
// Repository inspection is also the bounded reconciliation point for
|
|
730
|
+
// checkout lifecycle drift caused by worktrees being removed outside
|
|
731
|
+
// Forge. Reuse the existing registry reconciler so stale worktrees are
|
|
732
|
+
// not exposed as lifecycle=active to automation callers.
|
|
733
|
+
reconcileRepositoryCheckouts(repoIdValue, controllerHome);
|
|
626
734
|
const repository = getRepository(repoIdValue, controllerHome, { includeRemoved: args.include_removed === true });
|
|
627
735
|
return result(summarizeRepositoryInspection(repository, args.detail_level === 'detail'));
|
|
628
736
|
}
|
|
@@ -815,6 +923,7 @@ export async function callRepositoryTool(
|
|
|
815
923
|
controllerHome,
|
|
816
924
|
allowSoleRepository: true,
|
|
817
925
|
});
|
|
926
|
+
const binding = claimedSessionEditBinding(controllerHome, repository, caller, args.work_id);
|
|
818
927
|
const applied = withControllerLock(
|
|
819
928
|
controllerHome,
|
|
820
929
|
{ scope: 'repository', repoId: repository.repoId },
|
|
@@ -829,6 +938,7 @@ export async function callRepositoryTool(
|
|
|
829
938
|
continueOnError: args.continue_on_error,
|
|
830
939
|
refreshFingerprints: args.refresh_fingerprints,
|
|
831
940
|
recoverStaleSession: args.recover_stale_session,
|
|
941
|
+
binding,
|
|
832
942
|
}),
|
|
833
943
|
60_000,
|
|
834
944
|
);
|
|
@@ -892,7 +1002,7 @@ export async function callRepositoryTool(
|
|
|
892
1002
|
return ok ? result(payload) : { ...result(payload), isError: true };
|
|
893
1003
|
}
|
|
894
1004
|
case 'repository_command_preview': {
|
|
895
|
-
const target = resolveRepositoryCommandTarget(controllerHome, args, repoIdValue);
|
|
1005
|
+
const target = resolveRepositoryCommandTarget(controllerHome, args, repoIdValue, caller);
|
|
896
1006
|
const { repository } = target;
|
|
897
1007
|
const execution = withControllerLock(
|
|
898
1008
|
controllerHome,
|
|
@@ -912,8 +1022,11 @@ export async function callRepositoryTool(
|
|
|
912
1022
|
});
|
|
913
1023
|
}
|
|
914
1024
|
case 'repository_command_execute': {
|
|
915
|
-
const target = resolveRepositoryCommandTarget(controllerHome, args, repoIdValue);
|
|
1025
|
+
const target = resolveRepositoryCommandTarget(controllerHome, args, repoIdValue, caller);
|
|
916
1026
|
const { repository, executionIdentity } = target;
|
|
1027
|
+
if (activeWorkflowRawDefaultMergeBlocked(repository, executionIdentity.workId, args.command)) {
|
|
1028
|
+
throw new Error(`WORK_DELIVERY_REQUIRES_FINALIZE: ${executionIdentity.workId} must pass Work verification and use rh_work finalize before default-branch integration`);
|
|
1029
|
+
}
|
|
917
1030
|
const timeoutMs = typeof args.timeout_ms === 'number'
|
|
918
1031
|
? args.timeout_ms
|
|
919
1032
|
: typeof args.timeout_ms === 'string'
|
|
@@ -972,6 +1085,7 @@ export async function callRepositoryTool(
|
|
|
972
1085
|
maxOutputBytes,
|
|
973
1086
|
returnHandleImmediately,
|
|
974
1087
|
requestId: typeof args.request_id === 'string' ? args.request_id : undefined,
|
|
1088
|
+
workId: executionIdentity.workId,
|
|
975
1089
|
executionIdentity,
|
|
976
1090
|
allowNonGitWorkspace: target.workspace !== undefined,
|
|
977
1091
|
});
|
|
@@ -1111,6 +1225,7 @@ export async function callRepositoryTool(
|
|
|
1111
1225
|
timeoutMs,
|
|
1112
1226
|
maxOutputBytes,
|
|
1113
1227
|
requestId: typeof args.request_id === 'string' ? args.request_id : undefined,
|
|
1228
|
+
workId: executionIdentity.workId,
|
|
1114
1229
|
executionIdentity,
|
|
1115
1230
|
});
|
|
1116
1231
|
if (processResult.route === 'process_direct') {
|
package/src/cli/mcp/server.ts
CHANGED
|
@@ -48,6 +48,38 @@ function recordRequestId(args: Record<string, unknown>, rpcId: string | number |
|
|
|
48
48
|
return explicit || `mcp-rpc:${rpcId === undefined ? randomUUID() : String(rpcId)}`;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function firstTimingString(...values: unknown[]): string | undefined {
|
|
52
|
+
for (const value of values) {
|
|
53
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function timingIdentity(value: CallToolResult | undefined): {
|
|
59
|
+
repoId?: string;
|
|
60
|
+
workId?: string;
|
|
61
|
+
processId?: string;
|
|
62
|
+
route?: string;
|
|
63
|
+
} {
|
|
64
|
+
if (!value?.structuredContent || typeof value.structuredContent !== 'object' || Array.isArray(value.structuredContent)) return {};
|
|
65
|
+
const structured = value.structuredContent as Record<string, unknown>;
|
|
66
|
+
const work = structured.work && typeof structured.work === 'object' && !Array.isArray(structured.work)
|
|
67
|
+
? structured.work as Record<string, unknown>
|
|
68
|
+
: undefined;
|
|
69
|
+
const process = structured.process && typeof structured.process === 'object' && !Array.isArray(structured.process)
|
|
70
|
+
? structured.process as Record<string, unknown>
|
|
71
|
+
: undefined;
|
|
72
|
+
const resultRef = structured.resultRef && typeof structured.resultRef === 'object' && !Array.isArray(structured.resultRef)
|
|
73
|
+
? structured.resultRef as Record<string, unknown>
|
|
74
|
+
: undefined;
|
|
75
|
+
return {
|
|
76
|
+
repoId: firstTimingString(structured.repoId, structured.repo_id, work?.repositoryId, work?.repoId),
|
|
77
|
+
workId: firstTimingString(structured.workId, structured.work_id, work?.workId),
|
|
78
|
+
processId: firstTimingString(structured.processId, structured.process_id, process?.processId, resultRef?.processId),
|
|
79
|
+
route: firstTimingString(structured.route, structured.path, structured.mode, process?.route),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
51
83
|
function tracedResult(
|
|
52
84
|
value: CallToolResult | undefined,
|
|
53
85
|
traceId: string,
|
|
@@ -79,15 +111,18 @@ async function traceControllerMcpRequest(
|
|
|
79
111
|
name: string,
|
|
80
112
|
args: Record<string, unknown>,
|
|
81
113
|
rpcId: string | number | undefined,
|
|
82
|
-
handler: () => Promise<CallToolResult>,
|
|
114
|
+
handler: (requestId: string) => Promise<CallToolResult>,
|
|
83
115
|
): Promise<CallToolResult> {
|
|
116
|
+
const startedAtWall = new Date().toISOString();
|
|
84
117
|
const startedAt = performance.now();
|
|
85
118
|
const traceId = randomUUID();
|
|
86
119
|
const requestId = recordRequestId(args, rpcId);
|
|
87
120
|
let outcome: 'ok' | 'error' | 'exception' = 'ok';
|
|
88
121
|
let errorCode: string | undefined;
|
|
122
|
+
let executionIdentity: ReturnType<typeof timingIdentity> = {};
|
|
89
123
|
try {
|
|
90
|
-
const value = await handler();
|
|
124
|
+
const value = await handler(requestId);
|
|
125
|
+
executionIdentity = timingIdentity(value);
|
|
91
126
|
if (value?.isError) {
|
|
92
127
|
outcome = 'error';
|
|
93
128
|
const error = value.structuredContent && typeof value.structuredContent === 'object'
|
|
@@ -133,8 +168,11 @@ async function traceControllerMcpRequest(
|
|
|
133
168
|
traceId,
|
|
134
169
|
requestId,
|
|
135
170
|
...(rpcId === undefined ? {} : { rpcId }),
|
|
171
|
+
layer: ctx.runtimeSourceRoot ? 'canonical_runtime' : 'public_gateway',
|
|
172
|
+
startedAt: startedAtWall,
|
|
136
173
|
outcome,
|
|
137
174
|
...(errorCode ? { errorCode } : {}),
|
|
175
|
+
...executionIdentity,
|
|
138
176
|
totalToolDurationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
139
177
|
});
|
|
140
178
|
}
|
|
@@ -215,19 +253,117 @@ export async function readCanonicalRuntimeToolSchema(
|
|
|
215
253
|
};
|
|
216
254
|
}
|
|
217
255
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
256
|
+
interface CanonicalRuntimeProxyIdentity {
|
|
257
|
+
endpoint: URL;
|
|
258
|
+
token: string;
|
|
259
|
+
principalId: string;
|
|
260
|
+
sessionId: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function canonicalRuntimeProxyIdentity(ctx: MultiRepositoryMcpToolContext): CanonicalRuntimeProxyIdentity {
|
|
264
|
+
return {
|
|
265
|
+
endpoint: canonicalRuntimeEndpoint(ctx),
|
|
266
|
+
token: canonicalRuntimeToken(ctx),
|
|
267
|
+
principalId: ctx.principalId?.trim() ?? '',
|
|
268
|
+
sessionId: ctx.sessionId?.trim() ?? '',
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function sameCanonicalRuntimeProxyIdentity(
|
|
273
|
+
left: CanonicalRuntimeProxyIdentity,
|
|
274
|
+
right: CanonicalRuntimeProxyIdentity,
|
|
275
|
+
): boolean {
|
|
276
|
+
return left.endpoint.href === right.endpoint.href
|
|
277
|
+
&& left.token === right.token
|
|
278
|
+
&& left.principalId === right.principalId
|
|
279
|
+
&& left.sessionId === right.sessionId;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function createCanonicalRuntimeProxy(ctx: MultiRepositoryMcpToolContext): {
|
|
283
|
+
callTool: (name: string, args: Record<string, unknown>) => Promise<CallToolResult>;
|
|
284
|
+
close: () => Promise<void>;
|
|
285
|
+
} {
|
|
286
|
+
let current: { identity: CanonicalRuntimeProxyIdentity; client: Client } | undefined;
|
|
287
|
+
let connecting: Promise<{ identity: CanonicalRuntimeProxyIdentity; client: Client }> | undefined;
|
|
288
|
+
|
|
289
|
+
const closeCurrent = async (expectedClient?: Client): Promise<void> => {
|
|
290
|
+
if (!current || (expectedClient && current.client !== expectedClient)) return;
|
|
291
|
+
const closing = current.client;
|
|
292
|
+
current = undefined;
|
|
293
|
+
await closing.close().catch(() => undefined);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const connect = async (identity: CanonicalRuntimeProxyIdentity): Promise<{ identity: CanonicalRuntimeProxyIdentity; client: Client }> => {
|
|
297
|
+
const abort = new AbortController();
|
|
298
|
+
const timeout = setTimeout(() => abort.abort(new Error('CANONICAL_RUNTIME_TIMEOUT')), CANONICAL_RUNTIME_MCP_TIMEOUT_MS);
|
|
299
|
+
const headers: Record<string, string> = {
|
|
300
|
+
Authorization: `Bearer ${identity.token}`,
|
|
301
|
+
};
|
|
302
|
+
if (identity.principalId) headers['x-forge-forwarded-principal-id'] = identity.principalId;
|
|
303
|
+
if (identity.sessionId) headers['x-forge-forwarded-session-id'] = identity.sessionId;
|
|
304
|
+
const transport = new StreamableHTTPClientTransport(identity.endpoint, {
|
|
305
|
+
requestInit: { headers, signal: abort.signal },
|
|
306
|
+
});
|
|
307
|
+
const client = new Client({ name: 'forge-public-gateway-proxy', version: '1.0.0' });
|
|
308
|
+
try {
|
|
309
|
+
await client.connect(transport);
|
|
310
|
+
return { identity, client };
|
|
311
|
+
} catch (error) {
|
|
312
|
+
await client.close().catch(() => undefined);
|
|
313
|
+
throw error;
|
|
314
|
+
} finally {
|
|
315
|
+
clearTimeout(timeout);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const clientForCurrentRuntime = async (): Promise<Client> => {
|
|
320
|
+
const identity = canonicalRuntimeProxyIdentity(ctx);
|
|
321
|
+
if (current && sameCanonicalRuntimeProxyIdentity(current.identity, identity)) return current.client;
|
|
322
|
+
|
|
323
|
+
if (connecting) {
|
|
324
|
+
const pending = await connecting;
|
|
325
|
+
if (sameCanonicalRuntimeProxyIdentity(pending.identity, identity)) return pending.client;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
await closeCurrent();
|
|
329
|
+
connecting = connect(identity);
|
|
330
|
+
try {
|
|
331
|
+
current = await connecting;
|
|
332
|
+
return current.client;
|
|
333
|
+
} finally {
|
|
334
|
+
connecting = undefined;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
async callTool(name, args) {
|
|
340
|
+
const client = await clientForCurrentRuntime();
|
|
341
|
+
try {
|
|
342
|
+
// The outer Connector session is already bound to the Canonical Runtime
|
|
343
|
+
// schema and fenced by the Runtime-published fingerprint before dispatch.
|
|
344
|
+
// Re-listing tools here creates a second discovery round trip for every
|
|
345
|
+
// tool call without strengthening that fence.
|
|
346
|
+
const response = await client.callTool(
|
|
347
|
+
{ name, arguments: args },
|
|
348
|
+
undefined,
|
|
349
|
+
{ timeout: CANONICAL_RUNTIME_MCP_TIMEOUT_MS },
|
|
350
|
+
);
|
|
351
|
+
return response as unknown as CallToolResult;
|
|
352
|
+
} catch (error) {
|
|
353
|
+
// A Runtime restart, token rotation, or broken HTTP session must never
|
|
354
|
+
// poison later calls. The next request reconnects against fresh identity.
|
|
355
|
+
await closeCurrent(client);
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
async close() {
|
|
360
|
+
if (connecting) {
|
|
361
|
+
await connecting.catch(() => undefined);
|
|
362
|
+
connecting = undefined;
|
|
363
|
+
}
|
|
364
|
+
await closeCurrent();
|
|
365
|
+
},
|
|
366
|
+
};
|
|
231
367
|
}
|
|
232
368
|
|
|
233
369
|
function canonicalRuntimeSchemaMatchesGateway(ctx: MultiRepositoryMcpToolContext): boolean {
|
|
@@ -257,6 +393,12 @@ export function createForgeMcpServerFromContext(
|
|
|
257
393
|
{ name: 'forge-mcp', version: FORGE_VERSION },
|
|
258
394
|
{ capabilities: { tools: { listChanged: true } }, instructions: mcpServerInstructions(baseContext.policy.profile) },
|
|
259
395
|
);
|
|
396
|
+
const runtimeProxy = isMultiRepositoryContext(baseContext) && !getRuntimeWriteClaim()
|
|
397
|
+
? createCanonicalRuntimeProxy(baseContext)
|
|
398
|
+
: undefined;
|
|
399
|
+
server.onclose = () => {
|
|
400
|
+
void runtimeProxy?.close();
|
|
401
|
+
};
|
|
260
402
|
// The connector process can outlive the canonical Runtime release it proxies.
|
|
261
403
|
// Refresh tools after every MCP session initialization so clients do not keep
|
|
262
404
|
// a stale schema when the bounded Runtime surface changes behind the gateway.
|
|
@@ -279,7 +421,7 @@ export function createForgeMcpServerFromContext(
|
|
|
279
421
|
const ctx: ServerToolContext = { ...baseContext, signal: extra.signal };
|
|
280
422
|
if (isMultiRepositoryContext(ctx)) {
|
|
281
423
|
const rpcId = (request as unknown as { id?: unknown }).id;
|
|
282
|
-
return traceControllerMcpRequest(ctx, name, args, typeof rpcId === 'string' || typeof rpcId === 'number' ? rpcId : undefined, async () => {
|
|
424
|
+
return traceControllerMcpRequest(ctx, name, args, typeof rpcId === 'string' || typeof rpcId === 'number' ? rpcId : undefined, async (requestId) => {
|
|
283
425
|
// The public Gateway only exposes its stable facade schema. It may
|
|
284
426
|
// proxy execution, but it never discovers one Runtime schema and
|
|
285
427
|
// validates calls against another source checkout.
|
|
@@ -304,8 +446,11 @@ export function createForgeMcpServerFromContext(
|
|
|
304
446
|
// Gateway processes from acquiring Process Runtime leases or evaluating
|
|
305
447
|
// Runtime source coherence against their own checkout.
|
|
306
448
|
if (!getRuntimeWriteClaim()) {
|
|
307
|
-
|
|
308
|
-
|
|
449
|
+
const forwardedArgs = typeof args.request_id === 'string' && args.request_id.trim()
|
|
450
|
+
? args
|
|
451
|
+
: { ...args, request_id: requestId };
|
|
452
|
+
if (runtimeProxy && runtimeSchema) return runtimeProxy.callTool(name, forwardedArgs);
|
|
453
|
+
if (runtimeProxy && observeRuntimeStatus(ctx.controllerHome).ready) return runtimeProxy.callTool(name, forwardedArgs);
|
|
309
454
|
}
|
|
310
455
|
const accessResult = callAccessTool(ctx, name, args);
|
|
311
456
|
if (accessResult) return accessResult;
|
|
@@ -321,7 +466,7 @@ export function createForgeMcpServerFromContext(
|
|
|
321
466
|
if (runtimeResult) return runtimeResult;
|
|
322
467
|
const durableResult = await routeDurableMcpCall(ctx, name, args);
|
|
323
468
|
if (durableResult) return durableResult;
|
|
324
|
-
const repositoryResult = await callRepositoryTool(ctx.controllerHome, name, args);
|
|
469
|
+
const repositoryResult = await callRepositoryTool(ctx.controllerHome, name, args, ctx);
|
|
325
470
|
if (repositoryResult) return repositoryResult;
|
|
326
471
|
return callMultiRepositoryTool(ctx, name, args);
|
|
327
472
|
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { randomUUID, timingSafeEqual } from 'crypto';
|
|
2
|
+
import { existsSync, watch } from 'fs';
|
|
3
|
+
import { dirname } from 'path';
|
|
2
4
|
import express, { type Request, type Response, type NextFunction } from 'express';
|
|
3
5
|
import { tokenHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/token.js';
|
|
4
6
|
import { revocationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/revoke.js';
|
|
@@ -37,7 +39,7 @@ import { readForgeRuntimeStatus } from '../../../runtime/control-plane/runtime-s
|
|
|
37
39
|
import { runtimeIdentitySnapshot } from '../../../runtime/gateway/mcp/runtime-tools';
|
|
38
40
|
import { projectionBlocksReadiness, readRepositoryProjectionSnapshot } from '../../../runtime/projections/materialized-view';
|
|
39
41
|
import { readRuntimeGeneration } from '../../../runtime/control-plane/runtime-generation';
|
|
40
|
-
import { readRuntimeStatusSnapshot } from '../../../runtime/root/status';
|
|
42
|
+
import { readRuntimeStatusSnapshot, runtimeStatusPath } from '../../../runtime/root/status';
|
|
41
43
|
import { getRepository, listRepositories } from '../../repositories/registry';
|
|
42
44
|
import { buildControllerTaskLedgerProjection } from '../../controller/task-ledger';
|
|
43
45
|
import { legacyIssueAuthorityRetired } from '../../controller/legacy-issue-cutover';
|
|
@@ -740,6 +742,7 @@ async function handleMcpPost(
|
|
|
740
742
|
principalId,
|
|
741
743
|
});
|
|
742
744
|
const runtimeSchema = await resolveRuntimeSchema?.(sessionContext);
|
|
745
|
+
let server: ReturnType<typeof createForgeMcpServerFromContext> | undefined;
|
|
743
746
|
transport = new StreamableHTTPServerTransport({
|
|
744
747
|
sessionIdGenerator: () => randomUUID(),
|
|
745
748
|
onsessioninitialized: (newSessionId: string): void => {
|
|
@@ -752,6 +755,7 @@ async function handleMcpPost(
|
|
|
752
755
|
clientIdentity,
|
|
753
756
|
toolSurfaceFingerprint: runtimeSchema?.fingerprint ?? currentToolSurfaceFingerprint(),
|
|
754
757
|
toolNames: runtimeSchema?.toolNames,
|
|
758
|
+
notifyToolListChanged: async () => { await server?.sendToolListChanged(); },
|
|
755
759
|
});
|
|
756
760
|
initializedSessionId = newSessionId;
|
|
757
761
|
},
|
|
@@ -759,7 +763,7 @@ async function handleMcpPost(
|
|
|
759
763
|
transport.onclose = () => {
|
|
760
764
|
if (transport?.sessionId) registry.detach(transport.sessionId);
|
|
761
765
|
};
|
|
762
|
-
|
|
766
|
+
server = createForgeMcpServerFromContext(sessionContext, runtimeSchema);
|
|
763
767
|
await server.connect(transport);
|
|
764
768
|
await transport.handleRequest(req, res, body);
|
|
765
769
|
} finally {
|
|
@@ -917,6 +921,23 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
|
|
|
917
921
|
const currentRuntimeToolSurfaceFingerprint = () => runtimeControllerHome
|
|
918
922
|
? readRuntimeStatusSnapshot(runtimeControllerHome)?.toolSurfaceFingerprint
|
|
919
923
|
: undefined;
|
|
924
|
+
let observedRuntimeToolSurfaceFingerprint = currentRuntimeToolSurfaceFingerprint();
|
|
925
|
+
let toolSurfaceNotificationTimer: ReturnType<typeof setTimeout> | undefined;
|
|
926
|
+
const runtimeStatusDirectory = runtimeControllerHome ? dirname(runtimeStatusPath(runtimeControllerHome)) : undefined;
|
|
927
|
+
const runtimeStatusWatcher = runtimeStatusDirectory && existsSync(runtimeStatusDirectory)
|
|
928
|
+
? watch(runtimeStatusDirectory, { persistent: false }, (_event, filename) => {
|
|
929
|
+
if (String(filename ?? '') !== 'status.json') return;
|
|
930
|
+
if (toolSurfaceNotificationTimer) clearTimeout(toolSurfaceNotificationTimer);
|
|
931
|
+
toolSurfaceNotificationTimer = setTimeout(() => {
|
|
932
|
+
const currentFingerprint = currentRuntimeToolSurfaceFingerprint();
|
|
933
|
+
if (!currentFingerprint || currentFingerprint === observedRuntimeToolSurfaceFingerprint) return;
|
|
934
|
+
observedRuntimeToolSurfaceFingerprint = currentFingerprint;
|
|
935
|
+
void sessionRegistry.notifyToolListChanged(currentFingerprint);
|
|
936
|
+
}, 25);
|
|
937
|
+
toolSurfaceNotificationTimer.unref();
|
|
938
|
+
})
|
|
939
|
+
: undefined;
|
|
940
|
+
runtimeStatusWatcher?.on('error', () => undefined);
|
|
920
941
|
const resolveRuntimeSchema = runtimeControllerHome
|
|
921
942
|
? async (context: McpToolContext): Promise<CanonicalRuntimeToolSchema | undefined> => {
|
|
922
943
|
if (!('controllerHome' in context)) return undefined;
|
|
@@ -1260,6 +1281,8 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
|
|
|
1260
1281
|
|
|
1261
1282
|
httpServer.on('close', () => {
|
|
1262
1283
|
clearInterval(cleanupTimer);
|
|
1284
|
+
if (toolSurfaceNotificationTimer) clearTimeout(toolSurfaceNotificationTimer);
|
|
1285
|
+
runtimeStatusWatcher?.close();
|
|
1263
1286
|
void sessionRegistry.closeAll('shutdown');
|
|
1264
1287
|
});
|
|
1265
1288
|
|
|
@@ -31,6 +31,9 @@ export interface ManagedMcpSession<
|
|
|
31
31
|
toolSurfaceFingerprint?: string;
|
|
32
32
|
/** Exact tool names returned by the Canonical Runtime at initialize time. */
|
|
33
33
|
toolNames?: string[];
|
|
34
|
+
/** Standard MCP tools/list_changed notification bound to this session's connected Server. */
|
|
35
|
+
notifyToolListChanged?: () => Promise<void> | void;
|
|
36
|
+
lastNotifiedToolSurfaceFingerprint?: string;
|
|
34
37
|
createdAt: number;
|
|
35
38
|
lastActivityAt: number;
|
|
36
39
|
streamOpenedAt?: number;
|
|
@@ -88,6 +91,7 @@ interface RegisterMcpSession<TTransport extends ClosableMcpTransport, TContext>
|
|
|
88
91
|
clientIdentity: string;
|
|
89
92
|
toolSurfaceFingerprint?: string;
|
|
90
93
|
toolNames?: string[];
|
|
94
|
+
notifyToolListChanged?: () => Promise<void> | void;
|
|
91
95
|
initialPost?: boolean;
|
|
92
96
|
}
|
|
93
97
|
|
|
@@ -206,6 +210,26 @@ export class McpSessionRegistry<
|
|
|
206
210
|
if (session) session.lastActivityAt = this.now();
|
|
207
211
|
}
|
|
208
212
|
|
|
213
|
+
async notifyToolListChanged(currentFingerprint: string | undefined): Promise<number> {
|
|
214
|
+
if (!currentFingerprint) return 0;
|
|
215
|
+
let notified = 0;
|
|
216
|
+
await Promise.all(Array.from(this.sessions.values()).map(async (session) => {
|
|
217
|
+
if (!session.toolSurfaceFingerprint
|
|
218
|
+
|| session.toolSurfaceFingerprint === currentFingerprint
|
|
219
|
+
|| session.lastNotifiedToolSurfaceFingerprint === currentFingerprint
|
|
220
|
+
|| !session.notifyToolListChanged) return;
|
|
221
|
+
try {
|
|
222
|
+
await session.notifyToolListChanged();
|
|
223
|
+
session.lastNotifiedToolSurfaceFingerprint = currentFingerprint;
|
|
224
|
+
notified += 1;
|
|
225
|
+
} catch {
|
|
226
|
+
// Fingerprint fencing remains authoritative. A broken notification
|
|
227
|
+
// stream must not mark a stale session as refreshed or close active work.
|
|
228
|
+
}
|
|
229
|
+
}));
|
|
230
|
+
return notified;
|
|
231
|
+
}
|
|
232
|
+
|
|
209
233
|
beginPost(sessionId: string): ManagedMcpSession<TTransport, TContext> | undefined {
|
|
210
234
|
const session = this.sessions.get(sessionId);
|
|
211
235
|
if (!session) return undefined;
|