Skip to content
50 changes: 48 additions & 2 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as AutomationAuthority from "../scheduledTasks/DestinationAutomationAuthority.ts";
import { PendingRequests } from "../childThreads/PendingRequestService.ts";
import { PeerSavedPreviewAuthorizationRequest } from "@t3tools/contracts";
import * as McpRouting from "./McpRouting.ts";
Expand Down Expand Up @@ -231,6 +232,8 @@ const peerAuthorityFailure = (error: unknown) =>
{ status: isPeerFailure(error) && error.code === "orchestration_error" ? 503 : 403 },
);

const decodeAutomationDisable = Schema.decodeUnknownEffect(AutomationAuthority.AutomationDisable);

const makeMcpAuthMiddleware = Effect.gen(function* () {
const registry = yield* McpSessionRegistry.McpSessionRegistry;
const clients = yield* Effect.serviceOption(McpClientAuthenticator);
Expand All @@ -242,6 +245,22 @@ const makeMcpAuthMiddleware = Effect.gen(function* () {
authorization?.startsWith("Bearer ") === true
? authorization.slice("Bearer ".length).trim()
: "";
if (request.url.split("?")[0] === "/mcp/peer-control/automation-disable") {
if (request.method !== "POST" || Option.isNone(authority) || token.length === 0)
return HttpServerResponse.empty({ status: 403 });
const result = yield* Effect.gen(function* () {
const input = yield* decodeAutomationDisable(yield* request.json);
return yield* authority.value.receiveAutomationDisable(
input,
Hex.encode(sha256(new TextEncoder().encode(token))),
);
}).pipe(Effect.result);
return result._tag === "Failure"
? HttpServerResponse.empty({ status: 403 })
: result.success === undefined
? HttpServerResponse.empty({ status: 202 })
: HttpServerResponse.jsonUnsafe(result.success);
}
const invocation =
(yield* registry.resolve(token)) ??
(Option.isSome(clients) && token.length > 0
Expand Down Expand Up @@ -286,7 +305,11 @@ const makeMcpAuthMiddleware = Effect.gen(function* () {
const verified = yield* Effect.gen(function* () {
if (peerHeader !== undefined) {
const peer = yield* decodePeerSession(peerHeader);
yield* authority.value.enroll(limited, peer);
yield* authority.value.enroll(
limited,
peer,
Hex.encode(sha256(new TextEncoder().encode(token))),
);
}
if (envelopeHeader === undefined) return limited;
const envelope = yield* decodePeerEnvelope(envelopeHeader);
Expand Down Expand Up @@ -1226,6 +1249,7 @@ const layerSavedPreviewAuthorization = Layer.unwrap(
if (
scope.client?.linked !== true ||
scope.authority === undefined ||
!("envelope" in scope.authority) ||
input.environmentId !== scope.environmentId ||
input.generation !== scope.authority.peer.generation
)
Expand Down Expand Up @@ -1323,6 +1347,20 @@ export const layerDeviceToolkit = Layer.mergeAll(
const layerPeerControl = Layer.unwrap(
Effect.gen(function* () {
const authority = yield* Effect.serviceOption(PeerAuthority.PeerAuthority);
const automationSupport = HttpRouter.add(
"GET",
"/mcp/peer-control/automation",
Effect.succeed(
Option.isSome(authority)
? HttpServerResponse.jsonUnsafe({ version: 1 })
: HttpServerResponse.empty({ status: 409 }),
),
);
const automationDisable = HttpRouter.add(
"POST",
"/mcp/peer-control/automation-disable",
Effect.succeed(HttpServerResponse.empty({ status: 403 })),
);
const post = HttpRouter.add(
"POST",
"/mcp/peer-control",
Expand Down Expand Up @@ -1418,7 +1456,15 @@ const layerPeerControl = Layer.unwrap(
: HttpServerResponse.empty({ status: 409 }),
),
);
return Layer.mergeAll(post, streaming, tasks, requestProtocol, pendingRequestProtocol);
return Layer.mergeAll(
automationSupport,
automationDisable,
post,
streaming,
tasks,
requestProtocol,
pendingRequestProtocol,
);
}),
);

Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/mcp/McpRequestReceipts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { interactiveAuthority } from "../peer/PeerAuthorityRecord.ts";
import { isSqlError } from "effect/sql/SqlError";
import { OrchestratorMcpFailure } from "@t3tools/contracts";
import { sha256 } from "@noble/hashes/sha2";
Expand Down Expand Up @@ -438,7 +439,7 @@ export const ownedSqlMutation = <A, E, R>(perform: Effect.Effect<A, E, R>) =>
identity === undefined ||
Option.isNone(scope) ||
(scope.value.client?.linked === true &&
scope.value.authority?.envelope.requestOwner === undefined)
interactiveAuthority(scope.value.authority)?.envelope.requestOwner === undefined)
)
return yield* perform;
const receipts = yield* Effect.serviceOption(McpRequestReceipts);
Expand Down
13 changes: 9 additions & 4 deletions apps/server/src/mcp/McpRouting.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { interactiveAuthority } from "../peer/PeerAuthorityRecord.ts";
import { PeerDispatchAuthority } from "../peer/peerEffectAuthority.ts";
import * as ServerSettings from "../serverSettings.ts";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
Expand Down Expand Up @@ -145,7 +146,10 @@ export const withContext = <A, E, R>(
) {
const authority = yield* Effect.serviceOption(PeerAuthority.PeerAuthority);
if (Option.isNone(authority)) return yield* unavailable();
yield* authority.value.checkPayload(invocation.value.authority, currentTool.name, params);
const wire = interactiveAuthority(invocation.value.authority);
if (wire === undefined)
return yield* denied("A linked call requires its original interactive authority.");
yield* authority.value.checkPayload(wire, currentTool.name, params);
}
const decoded = target(params);
if (Option.isNone(decoded) || decoded.value.contextThreadId === undefined)
Expand Down Expand Up @@ -338,14 +342,15 @@ export const invoke = <P, A, E, R>(
tool !== undefined &&
effect === "external-unknown" &&
(Option.isSome(receipts) ||
(Option.isSome(scope) && scope.value.authority?.envelope.requestOwner !== undefined)) &&
(Option.isSome(scope) &&
interactiveAuthority(scope.value.authority)?.envelope.requestOwner !== undefined)) &&
typeof params === "object" &&
params !== null &&
"clientRequestId" in params &&
typeof params.clientRequestId === "string" &&
(Option.isNone(scope) ||
scope.value.client?.linked !== true ||
scope.value.authority?.envelope.requestOwner !== undefined)
interactiveAuthority(scope.value.authority)?.envelope.requestOwner !== undefined)
? { tool: tool.name, input: params }
: undefined,
),
Expand All @@ -359,7 +364,7 @@ export const invoke = <P, A, E, R>(
params !== null &&
"clientRequestId" in params &&
typeof params.clientRequestId === "string" &&
scope.value.authority?.envelope.requestOwner !== undefined
interactiveAuthority(scope.value.authority)?.envelope.requestOwner !== undefined
)
return yield* new OrchestratorMcpFailure({
code: "capability_denied",
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { interactiveAuthority } from "../peer/PeerAuthorityRecord.ts";
import {
executionPurposeOf,
executionPurposesEqual,
Expand Down Expand Up @@ -2225,7 +2226,8 @@ const make = Effect.gen(function* () {
const identity = yield* RequestReceipts.CurrentRequest;
if (
identity === undefined ||
(scope.client?.linked === true && scope.authority?.envelope.requestOwner === undefined)
(scope.client?.linked === true &&
interactiveAuthority(scope.authority)?.envelope.requestOwner === undefined)
)
return undefined;
if (scheduledTasks.mutationReceipt === undefined)
Expand Down
39 changes: 21 additions & 18 deletions apps/server/src/mcp/linkOrigin.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { interactiveAuthority } from "../peer/PeerAuthorityRecord.ts";
import * as PeerAuthority from "../peer/PeerAuthority.ts";
import * as Option from "effect/Option";
import {
Expand All @@ -23,23 +24,24 @@ export const callerLinkOrigin = (
caller: Pick<Caller, "scope"> & {
readonly caller: Pick<OrchestrationV2ThreadShell, "linkOrigin"> | undefined;
},
): OrchestrationV2LinkOrigin | undefined =>
(caller.scope.authority?.resourceSessionId !== undefined
? {
sessionId: caller.scope.authority.resourceSessionId,
label: caller.caller?.linkOrigin?.label ?? "linked environment",
}
: undefined) ??
(caller.scope.authority?.sessionId != null
? {
sessionId: caller.scope.authority.sessionId,
label: caller.scope.authority.peer.environmentId,
}
: undefined) ??
caller.caller?.linkOrigin ??
(caller.scope.client?.linked === true
? { sessionId: caller.scope.client.sessionId, label: caller.scope.client.label }
: undefined);
): OrchestrationV2LinkOrigin | undefined => {
const record = caller.scope.authority;
const resourceSessionId =
record !== undefined && "resourceSessionId" in record ? record.resourceSessionId : undefined;
if (resourceSessionId !== undefined)
return {
sessionId: resourceSessionId,
label: caller.caller?.linkOrigin?.label ?? "linked environment",
};
const wire = interactiveAuthority(record);
if (wire?.sessionId != null) return { sessionId: wire.sessionId, label: wire.peer.environmentId };
return (
caller.caller?.linkOrigin ??
(caller.scope.client?.linked === true
? { sessionId: caller.scope.client.sessionId, label: caller.scope.client.label }
: undefined)
);
};

/** Apply the link's resource scope to destination thread mutations. */
export const assertSameLink = <T extends Pick<OrchestrationV2ThreadShell, "linkOrigin">>(
Expand Down Expand Up @@ -90,7 +92,8 @@ const directFullAccessPeer = (caller: Pick<Caller, "scope" | "caller">) => {
return (
caller.caller === undefined &&
caller.scope.client?.linked === true &&
caller.scope.authority?.envelope.descendant !== true &&
(caller.scope.authority === undefined ||
interactiveAuthority(caller.scope.authority)?.envelope.descendant === false) &&
ceiling.runtimeMode === "full-access" &&
ceiling.interactionMode === "default"
);
Expand Down
21 changes: 13 additions & 8 deletions apps/server/src/mcp/requestIdentity.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { interactiveAuthority } from "../peer/PeerAuthorityRecord.ts";
import type { McpInvocationScope } from "./McpInvocationContext.ts";

export const threadRequestNamespace = (environmentId: string, threadId: string): string =>
`thread:${environmentId}:${threadId}`;

/** Credentials authenticate a job; renewing one must not rename its commands. */
export const requestNamespace = (scope: McpInvocationScope): string =>
scope.client?.linked === true && scope.authority?.envelope.requestOwner !== undefined
? scope.authority.envelope.requestOwner.namespace
: scope.thread !== undefined
? threadRequestNamespace(scope.environmentId, scope.thread.threadId)
: scope.requestNamespace;
export const requestNamespace = (scope: McpInvocationScope): string => {
const owner = interactiveAuthority(scope.authority)?.envelope.requestOwner;
if (scope.client?.linked === true && owner !== undefined) return owner.namespace;
return scope.thread !== undefined
? threadRequestNamespace(scope.environmentId, scope.thread.threadId)
: scope.requestNamespace;
};

/** Keep the generation separate from the key so replacement denies rather than duplicates. */
export const requestGeneration = (scope: McpInvocationScope): string =>
scope.authority?.envelope.requestOwner?.generation ??
scope.authority?.peer.generation ??
interactiveAuthority(scope.authority)?.envelope.requestOwner?.generation ??
interactiveAuthority(scope.authority)?.peer.generation ??
(scope.authority !== undefined && "generation" in scope.authority
? scope.authority.generation
: undefined) ??
(scope.client?.linked === true ? `legacy:${scope.client.sessionId}` : "local");
4 changes: 3 additions & 1 deletion apps/server/src/mcp/toolkits/environment/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { interactiveAuthority } from "../../../peer/PeerAuthorityRecord.ts";
import { OrchestratorMcpFailure, type ServerSettings } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import { CurrentRequest, externalDispatch, externalReplay } from "../../McpRequestReceipts.ts";
Expand Down Expand Up @@ -68,7 +69,8 @@ export const layer = McpToolAccess.toLayer(EnvironmentToolkit, {
const identity = yield* CurrentRequest;
const negotiated =
identity !== undefined &&
(scope.client?.linked !== true || scope.authority?.envelope.requestOwner !== undefined);
(scope.client?.linked !== true ||
interactiveAuthority(scope.authority)?.envelope.requestOwner !== undefined);
if (!negotiated)
return preferences(
yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)),
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/mcp/toolkits/preview/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { interactiveAuthority } from "../../../peer/PeerAuthorityRecord.ts";
import { CurrentRequest } from "../../McpRequestReceipts.ts";
import { loadCaller } from "../../threadAccess.ts";
import { assertSameLink } from "../../linkOrigin.ts";
Expand Down Expand Up @@ -308,7 +309,8 @@ const handlers = {
const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview");
const staging = yield* Effect.serviceOption(AttachmentStaging.AttachmentStaging);
const modern =
scope.client?.linked === true && scope.authority?.envelope.requestOwner !== undefined;
scope.client?.linked === true &&
interactiveAuthority(scope.authority)?.envelope.requestOwner !== undefined;
const request = yield* CurrentRequest;
if ((modern || request !== undefined) && Option.isNone(staging))
return yield* new OrchestratorMcpFailure({
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/mcp/toolkits/pullRequests/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { interactiveAuthority } from "../../../peer/PeerAuthorityRecord.ts";
import { CurrentRequest } from "../../McpRequestReceipts.ts";
import {
threadPullRequestsOf,
Expand Down Expand Up @@ -209,7 +210,8 @@ const make = Effect.gen(function* () {
const negotiatedRequest = Effect.gen(function* () {
const request = yield* CurrentRequest;
const scope = yield* McpInvocationContext.McpInvocationContext;
return scope.client?.linked === true && scope.authority?.envelope.requestOwner === undefined
return scope.client?.linked === true &&
interactiveAuthority(scope.authority)?.envelope.requestOwner === undefined
? undefined
: request;
});
Expand Down
8 changes: 4 additions & 4 deletions apps/server/src/orchestration-v2/EffectWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,7 +778,7 @@ export const layerWithOptions = (
"provider-thread.rollback",
].includes(effect.request.type)
? authority.value.checkEffect(effect.id, effect.threadId).pipe(
Effect.asVoid,
Effect.map((record) => record?.id),
Effect.mapError(
(cause) =>
new OrchestrationEffectExecutionError({
Expand All @@ -788,7 +788,7 @@ export const layerWithOptions = (
}),
),
)
: Effect.void;
: Effect.succeed(undefined);
const stopOwner =
Option.isSome(authority) &&
["provider-turn.interrupt", "provider-session.detach", "delegated-tasks.stop"].includes(
Expand All @@ -807,12 +807,12 @@ export const layerWithOptions = (
: Effect.succeed(undefined);
const execution = authorized
.pipe(
Effect.andThen(
Effect.flatMap((executionOwner) =>
stopOwner.pipe(
Effect.flatMap((owner) =>
executor
.execute(effect, { willRetry: effect.attemptCount < maxAttempts })
.pipe(Effect.provideService(PeerDispatchAuthority, owner)),
.pipe(Effect.provideService(PeerDispatchAuthority, owner ?? executionOwner)),
),
),
),
Expand Down
Loading
Loading