Skip to content

Commit bc951d1

Browse files
feat(server): every tool that names a thread, project or queue reaches a linked environment
Forwarding was wired tool by tool, so only the first eleven tools took environmentId. It now lives in McpToolAccess.toLayer: a tool whose parameters take OrchestratorMcpEnvironmentTarget runs in the linked environment it names once its declaration has checked the caller here. The local handler never sees environmentId. Tools that act as the calling thread itself cannot take one, and the layer refuses to build if they do. Queues, pending requests, thread settings and organizing, forks and merge-backs, scheduled tasks, pull requests, worktree refs, project read/update/delete and environment preferences now reach a link too. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 36c7522 commit bc951d1

17 files changed

Lines changed: 535 additions & 292 deletions

File tree

‎apps/server/src/mcp/McpToolAccess.test.ts‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
import { expect, it } from "@effect/vitest";
22
import {
33
EnvironmentId,
4+
OrchestratorMcpEnvironmentTarget,
45
OrchestratorMcpFailure,
56
ProviderInstanceId,
67
ProviderInteractionMode,
78
RuntimeMode,
89
ThreadId,
910
} from "@t3tools/contracts";
11+
import * as Cause from "effect/Cause";
1012
import * as Effect from "effect/Effect";
13+
import * as Exit from "effect/Exit";
1114
import * as Layer from "effect/Layer";
1215
import * as Schema from "effect/Schema";
1316
import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
1417

1518
import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts";
19+
import * as PeerForwarding from "../peer/PeerForwarding.ts";
1620
import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
1721
import * as McpHttpServer from "./McpHttpServer.ts";
1822
import * as McpInvocationContext from "./McpInvocationContext.ts";
@@ -339,3 +343,112 @@ it.effect("refuses a change when the calling thread cannot be read", () =>
339343
),
340344
),
341345
);
346+
347+
const box = EnvironmentId.make("environment-box");
348+
const here = EnvironmentId.make("environment");
349+
const linkedProbe = {
350+
...probe,
351+
parameters: Schema.Struct({
352+
environmentId: OrchestratorMcpEnvironmentTarget,
353+
threadId: Schema.optional(ThreadId),
354+
runtimeMode: Schema.optional(RuntimeMode),
355+
interactionMode: Schema.optional(ProviderInteractionMode),
356+
}),
357+
};
358+
const LinkedProbeToolkit = Toolkit.make(
359+
Tool.make("reads", linkedProbe),
360+
Tool.make("writes", linkedProbe),
361+
Tool.make("writes_with_capability", linkedProbe),
362+
Tool.make("writes_threads", linkedProbe),
363+
Tool.make("starts_threads", linkedProbe),
364+
Tool.make("writes_environment", linkedProbe),
365+
);
366+
/** Runs here, and tells whether `environmentId` reached the handler. */
367+
const ranHere = <P extends object>(params: P) =>
368+
Effect.succeed({ ran: "environmentId" in params ? "saw environmentId" : "here" });
369+
370+
const linkedProbeServer = McpHttpServer.toolkitRegistration(
371+
LinkedProbeToolkit,
372+
McpToolAccess.toLayer(LinkedProbeToolkit, {
373+
reads: McpToolAccess.reads(ranHere),
374+
writes: McpToolAccess.writes(ranHere),
375+
writes_with_capability: McpToolAccess.writesWithCapability(ranHere),
376+
writes_threads: McpToolAccess.writesThreads((input) => [input.threadId], ranHere),
377+
starts_threads: McpToolAccess.startsThreads((input) => input, ranHere),
378+
writes_environment: McpToolAccess.writesEnvironment(ranHere),
379+
}),
380+
).pipe(
381+
Layer.provideMerge(McpServer.McpServer.layer),
382+
Layer.provideMerge(
383+
Layer.mock(ThreadManagement.ThreadManagementService)({
384+
getThreadShell: (threadId) => Effect.succeed(shells.get(threadId) ?? null),
385+
}),
386+
),
387+
// Stands in for the linked environment, answering with where the call went.
388+
Layer.provide(
389+
Layer.mock(PeerForwarding.PeerForwarding)({
390+
call: (_scope, tool, environmentId) =>
391+
Effect.succeed({ ran: `${tool.name} in ${environmentId}` } as never),
392+
}),
393+
),
394+
);
395+
396+
it.effect.each([
397+
["reads", supervised, { environmentId: box }, "reads in environment-box"],
398+
["reads", supervised, { environmentId: here }, "here"],
399+
["reads", supervised, {}, "here"],
400+
401+
// The caller is checked here before anything goes.
402+
["writes", readOnlyClient, { environmentId: box }, "capability_denied"],
403+
["writes", ended, { environmentId: box }, "parent_not_active"],
404+
["writes", supervised, { environmentId: box }, "writes in environment-box"],
405+
["writes_with_capability", readOnlyClient, { environmentId: box }, "capability_denied"],
406+
[
407+
"starts_threads",
408+
plan,
409+
{ environmentId: box, interactionMode: "default" },
410+
"interaction_mode_escalation_denied",
411+
],
412+
["starts_threads", plan, { environmentId: box }, "starts_threads in environment-box"],
413+
["writes_environment", supervised, { environmentId: box }, "capability_denied"],
414+
[
415+
"writes_environment",
416+
fullAccess,
417+
{ environmentId: box },
418+
"writes_environment in environment-box",
419+
],
420+
["writes_environment", fullAccess, { environmentId: here }, "here"],
421+
422+
// A thread id names a thread where the call runs, and is checked there.
423+
[
424+
"writes_threads",
425+
supervised,
426+
{ environmentId: box, threadId: fullAccessThreadId },
427+
"writes_threads in environment-box",
428+
],
429+
[
430+
"writes_threads",
431+
supervised,
432+
{ environmentId: here, threadId: fullAccessThreadId },
433+
"runtime_mode_escalation_denied",
434+
],
435+
] as const)("a linked %s from %o with %o: %s", ([tool, scope, args, expected]) =>
436+
call(tool, scope, args).pipe(
437+
Effect.map((outcome) => expect(outcome).toBe(expected)),
438+
Effect.provide(linkedProbeServer),
439+
),
440+
);
441+
442+
it.effect("refuses a linked environment to a tool that acts as its calling thread", () =>
443+
Effect.gen(function* () {
444+
const CallerToolkit = Toolkit.make(Tool.make("acts_as_caller", linkedProbe));
445+
const built = yield* Layer.build(
446+
McpToolAccess.HandlersLayer.layer(
447+
McpToolAccess.toLayer(CallerToolkit, {
448+
acts_as_caller: McpToolAccess.actsAsCaller(ranHere),
449+
}),
450+
),
451+
).pipe(Effect.scoped, Effect.exit);
452+
expect(Exit.isFailure(built) && Cause.hasDies(built.cause)).toBe(true);
453+
}),
454+
);

0 commit comments

Comments
 (0)