Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
import { expect, it } from "@effect/vitest";
import { NodeHttpServer } from "@effect/platform-node";
import * as NodeServices from "@effect/platform-node/NodeServices";
Expand All @@ -8,6 +9,8 @@ import {
OrchestratorMcpFailure,
PreviewTabId,
ProviderInstanceId,
ProviderInteractionMode,
RuntimeMode,
ThreadId,
} from "@t3tools/contracts";
import * as Deferred from "effect/Deferred";
Expand Down Expand Up @@ -1085,3 +1088,145 @@ it.effect("admits provider and OAuth client credentials and points only clients
}),
).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect(
"narrows an OAuth client's modes to the T3-Mode-Limit it sends, and never widens them",
() =>
Effect.scoped(
Effect.gen(function* () {
const clientToken = "client-payload.client-signature";
const clientScope: McpInvocationContext.McpInvocationScope = {
environmentId,
requestNamespace: "client:linked",
thread: undefined,
client: { sessionId: "linked", label: "T3 Code · laptop", access: "auto" },
capabilities: new Set(["orchestration"]),
issuedAt: 1,
};
// Starts a thread at the modes the gate hands it, so the outcome is those modes.
const StartsToolkit = Toolkit.make(
Tool.make("starts", {
description: "Reports the modes a started thread would run with.",
parameters: Schema.Struct({
runtimeMode: Schema.optional(RuntimeMode),
interactionMode: Schema.optional(ProviderInteractionMode),
}),
success: Schema.Struct({ modes: Schema.String }),
failure: OrchestratorMcpFailure,
failureMode: "return",
dependencies: [
McpInvocationContext.McpInvocationContext,
ThreadManagementService.ThreadManagementService,
],
}),
);
const serverLayer = McpHttpServer.toolkitRegistration(
StartsToolkit,
McpToolAccess.toLayer(StartsToolkit, {
starts: McpToolAccess.startsThreads(
(input) => input,
(_, modes) =>
Effect.succeed({ modes: `${modes.runtimeMode}/${modes.interactionMode}` }),
),
}),
).pipe(
Layer.provideMerge(McpHttpServer.layerMcpTransport),
Layer.provide(McpToolAccessTestkit.liveThreadsLayer),
Layer.provide(
Layer.mock(McpSessionRegistry.McpSessionRegistry)({
resolve: () => Effect.succeed(undefined),
}),
),
Layer.provide(
Layer.succeed(McpHttpServer.McpClientAuthenticator, {
authenticate: (request) =>
Effect.succeed(
request.headers.authorization === `Bearer ${clientToken}`
? clientScope
: undefined,
),
}),
),
);
yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe(
Layer.build,
);
const httpClient = yield* HttpClient.HttpClient;
const decodeOutcome = Schema.decodeUnknownEffect(
Schema.fromJsonString(
Schema.Struct({
result: Schema.Struct({
content: Schema.Array(Schema.Struct({ text: Schema.String })),
}),
}),
),
);
const decodeText = Schema.decodeUnknownEffect(
Schema.fromJsonString(
Schema.Union([
Schema.Struct({ modes: Schema.String }),
Schema.Struct({ code: Schema.String }),
]),
),
);
/** Starts a thread as the client, with `limit` as T3-Mode-Limit; the HTTP status or the outcome. */
const start = (limit: string | undefined, args: Record<string, string> = {}) =>
Effect.gen(function* () {
const headers = {
accept: "application/json, text/event-stream",
authorization: `Bearer ${clientToken}`,
...(limit === undefined ? {} : { "t3-mode-limit": limit }),
};
const initialize = yield* httpClient.post("/mcp", {
headers,
body: HttpBody.text(
`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`,
"application/json",
),
});
if (initialize.status !== 200) return initialize.status;
const response = yield* httpClient.post("/mcp", {
headers: {
...headers,
"mcp-session-id": initialize.headers["mcp-session-id"]!,
"mcp-protocol-version": "2025-06-18",
},
body: HttpBody.text(
JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: { name: "starts", arguments: args },
}),
"application/json",
),
});
const body = yield* decodeOutcome(yield* response.text);
const outcome = yield* decodeText(
body.result.content.map((part) => part.text).join(""),
);
return "code" in outcome ? outcome.code : outcome.modes;
});

// Approved for auto: without the header the client keeps its ceiling.
expect(yield* start(undefined)).toBe("auto/default");
// The header narrows both modes, and asking above it is refused.
expect(yield* start("approval-required/plan")).toBe("approval-required/plan");
expect(yield* start("approval-required/plan", { runtimeMode: "auto" })).toBe(
"runtime_mode_escalation_denied",
);
expect(yield* start("auto-accept-edits/plan", { interactionMode: "default" })).toBe(
"interaction_mode_escalation_denied",
);
// A broader header changes nothing: the approved ceiling still holds.
expect(yield* start("full-access/default")).toBe("auto/default");
expect(yield* start("full-access/default", { runtimeMode: "full-access" })).toBe(
"runtime_mode_escalation_denied",
);
// A malformed header is refused before anything runs.
expect(yield* start("auto")).toBe(400);
expect(yield* start("auto/default/extra")).toBe(400);
expect(yield* start("root/default")).toBe(400);
}),
).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
40 changes: 38 additions & 2 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import * as Stream from "effect/Stream";
import type * as Types from "effect/Types";
import { AiError, McpProtocol, McpSchema, McpServer, Tool, type Toolkit } from "effect/ai";
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts";
import {
OrchestratorMcpFailure,
PreviewAutomationError,
ProviderInteractionMode,
RuntimeMode,
} from "@t3tools/contracts";

import packageJson from "../../package.json" with { type: "json" };
import * as ServerConfig from "../config.ts";
Expand Down Expand Up @@ -139,6 +144,35 @@ export const normalizeMcpHttpResponse = (
// Session tokens are `<payload>.<signature>`; registry tokens are a bare base64url secret.
const looksLikeProviderToken = (token: string) => token.length > 0 && !token.includes(".");

const decodeModeLimit = Schema.decodeUnknownOption(
Schema.Struct({ runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode }),
);
const parseModeLimit = (value: string) => {
const [runtimeMode, interactionMode, ...rest] = value.trim().split("/");
return rest.length === 0 ? decodeModeLimit({ runtimeMode, interactionMode }) : Option.none();
};

const invalidModeLimit = HttpServerResponse.jsonUnsafe(
{
error: "invalid_mode_limit",
message: "T3-Mode-Limit must be <runtimeMode>/<interactionMode>, e.g. auto/default.",
},
{ status: 400, headers: { "cache-control": "no-store" } },
);

/** Applies a client's requested mode limit; undefined when the header is malformed. */
const withModeLimit = (
invocation: McpInvocationContext.McpInvocationScope,
request: HttpServerRequest.HttpServerRequest,
): McpInvocationContext.McpInvocationScope | undefined => {
const header = request.headers[McpInvocationContext.MODE_LIMIT_HEADER];
if (header === undefined || invocation.client === undefined) return invocation;
const limit = parseModeLimit(header);
return Option.isNone(limit)
? undefined
: { ...invocation, client: { ...invocation.client, narrowedTo: limit.value } };
};

const makeMcpAuthMiddleware = Effect.gen(function* () {
const registry = yield* McpSessionRegistry.McpSessionRegistry;
const clients = yield* Effect.serviceOption(McpClientAuthenticator);
Expand All @@ -164,8 +198,10 @@ const makeMcpAuthMiddleware = Effect.gen(function* () {
offerOAuth: Option.isSome(clients) && !looksLikeProviderToken(token),
});
}
const limited = withModeLimit(invocation, request);
if (limited === undefined) return invalidModeLimit;
return yield* httpEffect.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
Effect.provideService(McpInvocationContext.McpInvocationContext, limited),
Effect.map(normalizeMcpHttpResponse),
);
}) satisfies McpAuthMiddleware;
Expand Down
54 changes: 50 additions & 4 deletions apps/server/src/mcp/McpInvocationContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
OrchestratorMcpFailure,
PreviewAutomationUnavailableError,
type ProviderInstanceId,
type ProviderInteractionMode,
type RuntimeMode,
type ThreadId,
} from "@t3tools/contracts";
Expand All @@ -30,14 +31,59 @@ export interface McpClientCaller {
readonly sessionId: string;
readonly label: string;
readonly access: AuthMcpClientAccess;
/**
* Narrower modes the client asked for on this request (`T3-Mode-Limit`):
* another environment passing on the limits of the agent it calls for.
*/
readonly narrowedTo?: ClientModes | undefined;
}

/**
* A read-only client never reaches a write (`McpToolAccess` refuses it first),
* so it maps to the lowest mode rather than to nothing.
* `T3-Mode-Limit: <runtimeMode>/<interactionMode>` lets an OAuth client cap
* a request below what it was approved with: another environment passes on
* the limits of the agent it calls for. It can only narrow (see
* `clientModeCeiling`), so a client gains nothing by sending a broad one.
*/
export const clientRuntimeModeCeiling = (client: McpClientCaller | undefined): RuntimeMode =>
client === undefined || client.access === "read-only" ? "approval-required" : client.access;
export const MODE_LIMIT_HEADER = "t3-mode-limit";

export interface ClientModes {
readonly runtimeMode: RuntimeMode;
readonly interactionMode: ProviderInteractionMode;
}

const runtimeModeRank: Record<RuntimeMode, number> = {
"approval-required": 0,
"auto-accept-edits": 1,
auto: 2,
"full-access": 3,
};
const interactionModeRank: Record<ProviderInteractionMode, number> = { plan: 0, default: 1 };

/**
* The modes a client caller's writes are capped at: what it was approved
* with, narrowed by what it asked for on this request. A read-only client
* never reaches a write (`McpToolAccess` refuses it first), so it maps to the
* lowest runtime mode rather than to nothing.
*/
export const clientModeCeiling = (client: McpClientCaller | undefined): ClientModes => {
const approved: ClientModes = {
runtimeMode:
client === undefined || client.access === "read-only" ? "approval-required" : client.access,
interactionMode: "default",
};
const asked = client?.narrowedTo;
if (asked === undefined) return approved;
return {
runtimeMode:
runtimeModeRank[asked.runtimeMode] < runtimeModeRank[approved.runtimeMode]
? asked.runtimeMode
: approved.runtimeMode,
interactionMode:
interactionModeRank[asked.interactionMode] < interactionModeRank[approved.interactionMode]
? asked.interactionMode
: approved.interactionMode,
};
};

/**
* A thread caller's omitted target falls back to its own thread; a client
Expand Down
7 changes: 2 additions & 5 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
import * as ServerSettings from "../serverSettings.ts";
import {
clientRuntimeModeCeiling,
clientModeCeiling,
type McpInvocationScope,
type McpThreadInvocationScope,
requireThreadScope,
Expand Down Expand Up @@ -974,10 +974,7 @@ const make = Effect.gen(function* () {
if (scope.thread === undefined) {
return {
parent: undefined,
limits: {
runtimeMode: clientRuntimeModeCeiling(scope.client),
interactionMode: "default",
} satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode },
limits: clientModeCeiling(scope.client),
} as const;
}
const parent = yield* loadProjection(scope.thread.threadId);
Expand Down
5 changes: 1 addition & 4 deletions apps/server/src/mcp/threadAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,7 @@ export const loadCaller = Effect.fn("mcp.loadCaller")(function* () {
scope,
threads,
caller: undefined,
limits: {
runtimeMode: McpInvocationContext.clientRuntimeModeCeiling(scope.client),
interactionMode: "default",
},
limits: McpInvocationContext.clientModeCeiling(scope.client),
} satisfies Caller;
}
const caller = yield* threads
Expand Down
Loading