Skip to content
Merged
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
7 changes: 7 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import * as McpInvocationContext from "./McpInvocationContext.ts";
import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts";
import { PreviewControlsHandlersLive } from "./toolkits/previewControls/handlers.ts";
import { EnvironmentToolkit } from "./toolkits/environment/tools.ts";
import { EnvironmentHandlersLive } from "./toolkits/environment/handlers.ts";
import { ThreadToolkit } from "./toolkits/thread/tools.ts";
import { ThreadToolkitHandlersLive } from "./toolkits/thread/handlers.ts";
import * as ThreadMetadataMcpService from "./ThreadMetadataMcpService.ts";
Expand Down Expand Up @@ -245,6 +247,10 @@ export const PreviewControlsRegistrationLive = McpServer.toolkit(PreviewControls
Layer.provide(PreviewControlsHandlersLive),
);

export const EnvironmentRegistrationLive = McpServer.toolkit(EnvironmentToolkit).pipe(
Layer.provide(EnvironmentHandlersLive),
);

const McpTransportLive = McpServer.layerHttp({
name: "T3 Code",
version: packageJson.version,
Expand All @@ -256,6 +262,7 @@ export const layer = Layer.mergeAll(
PreviewToolkitRegistrationLive,
OrchestratorToolkitRegistrationLive,
ThreadToolkitRegistrationLive,
EnvironmentRegistrationLive,
PreviewControlsRegistrationLive,
WorktreeToolkitRegistrationLive,
).pipe(Layer.provideMerge(McpTransportLive));
28 changes: 27 additions & 1 deletion apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import { expect, it } from "@effect/vitest";
import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import {
DEFAULT_SERVER_SETTINGS,
EnvironmentId,
ProviderInstanceId,
ThreadId,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { McpSchema, McpServer, Tool } from "effect/unstable/ai";
Expand All @@ -12,6 +17,8 @@ import { McpInvocationContext, type McpInvocationScope } from "../McpInvocationC
import { OrchestratorToolkit } from "./orchestrator/tools.ts";
import { PreviewToolkit } from "./preview/tools.ts";
import { PreviewControlsToolkit } from "./previewControls/tools.ts";
import { EnvironmentToolkit } from "./environment/tools.ts";
import * as EnvironmentHandlers from "./environment/handlers.ts";
import { ThreadToolkit } from "./thread/tools.ts";
import { WorktreeToolkit } from "./worktree/tools.ts";

Expand All @@ -22,6 +29,7 @@ it("publishes unique tool names with object-root inputs", () => {
PreviewToolkit,
WorktreeToolkit,
ThreadToolkit,
EnvironmentToolkit,
PreviewControlsToolkit,
]) {
for (const tool of Object.values(toolkit.tools)) {
Expand Down Expand Up @@ -108,3 +116,21 @@ it.effect("returns a bounded public failure without serializing storage causes",
),
),
);

it("keeps MCP preference output allowlisted and Unicode-bounded", () => {
const settings = {
...DEFAULT_SERVER_SETTINGS,
privateCredential: "must-not-escape",
sourceControlWritingStyle: {
...DEFAULT_SERVER_SETTINGS.sourceControlWritingStyle,
customInstructions: "🙂".repeat(4001),
},
};
const result = EnvironmentHandlers.preferences(settings);
expect(result).not.toHaveProperty("privateCredential");
expect(result).not.toHaveProperty("providers");
expect(result.sourceControlWritingStyle).toMatchObject({
customInstructions: "🙂".repeat(4000),
truncated: true,
});
});
79 changes: 79 additions & 0 deletions apps/server/src/mcp/toolkits/environment/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { OrchestratorMcpFailure, type ServerSettings } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Environment from "../../../environment/ServerEnvironment.ts";
import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts";
import * as Settings from "../../../serverSettings.ts";
import { McpInvocationContext } from "../../McpInvocationContext.ts";
import { readCaller, readMutationCaller, unavailable } from "../../threadAccess.ts";
import { EnvironmentToolkit } from "./tools.ts";

export function preferences(settings: ServerSettings) {
const {
defaultThreadEnvMode,
newWorktreesStartFromOrigin,
enableProviderUpdateChecks,
backgroundActivity,
sourceControlWritingStyle,
} = settings;
const characters = Array.from(sourceControlWritingStyle.customInstructions);
return {
defaultThreadEnvMode,
newWorktreesStartFromOrigin,
enableProviderUpdateChecks,
backgroundActivity: { profile: backgroundActivity.profile },
sourceControlWritingStyle: {
...sourceControlWritingStyle,
customInstructions: characters.slice(0, 4000).join(""),
truncated: characters.length > 4000,
},
};
}
const access = (writable = false) =>
Effect.gen(function* () {
const context = yield* writable ? readMutationCaller() : readCaller();
const environment = yield* Environment.ServerEnvironment;
const descriptor = yield* environment.getDescriptor;
if (descriptor.environmentId !== context.scope.environmentId)
return yield* new OrchestratorMcpFailure({
code: "capability_denied",
message: "This credential belongs to another environment.",
});
return { ...context, descriptor, settings: yield* Settings.ServerSettingsService };
});
export const EnvironmentHandlersLive = EnvironmentToolkit.toLayer({
t3_environment_read: () =>
Effect.gen(function* () {
const { descriptor, settings } = yield* access();
const current = yield* settings.getSettings.pipe(Effect.mapError(unavailable));
return {
environmentId: descriptor.environmentId,
label: descriptor.label,
serverVersion: descriptor.serverVersion,
platform: descriptor.platform,
preferences: preferences(current),
};
}),
t3_environment_preferences_update: (patch) =>
Effect.gen(function* () {
const scope = yield* McpInvocationContext;
const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor;
return yield* executor.withLock(
scope.threadId,
Effect.gen(function* () {
const { caller, settings } = yield* access(true);
if (
caller.archivedAt !== null ||
caller.runtimeMode !== "full-access" ||
caller.interactionMode !== "default"
)
return yield* new OrchestratorMcpFailure({
code: "capability_denied",
message: "Preference updates require a live full-access/default thread.",
});
return preferences(
yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)),
);
}),
);
}),
Comment thread
juliusmarminge marked this conversation as resolved.
});
67 changes: 67 additions & 0 deletions apps/server/src/mcp/toolkits/environment/tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
BackgroundActivityProfile,
BackgroundActivityProfileSelection,
ExecutionEnvironmentDescriptor,
OrchestratorMcpFailure,
ServerSettings,
ServerSettingsPatch,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import { Tool, Toolkit } from "effect/unstable/ai";
import { ServerEnvironment } from "../../../environment/ServerEnvironment.ts";
import { ThreadCommandExecutor } from "../../../orchestration-v2/ThreadCommandExecutor.ts";
import { ThreadManagementService } from "../../../orchestration-v2/ThreadManagementService.ts";
import { ServerSettingsService } from "../../../serverSettings.ts";
import { McpInvocationContext } from "../../McpInvocationContext.ts";

export const PreferenceFields = {
defaultThreadEnvMode: ServerSettings.fields.defaultThreadEnvMode,
newWorktreesStartFromOrigin: ServerSettings.fields.newWorktreesStartFromOrigin,
enableProviderUpdateChecks: ServerSettings.fields.enableProviderUpdateChecks,
backgroundActivity: Schema.Struct({ profile: BackgroundActivityProfileSelection }),
sourceControlWritingStyle: Schema.Struct({
mode: Schema.String,
followChangeRequestTemplates: Schema.Boolean,
customInstructions: Schema.String,
truncated: Schema.Boolean,
}),
};
const shared = {
failure: OrchestratorMcpFailure,
failureMode: "return" as const,
dependencies: [
McpInvocationContext,
ThreadManagementService,
ServerEnvironment,
ServerSettingsService,
ThreadCommandExecutor,
],
};
export const EnvironmentReadTool = Tool.make("t3_environment_read", {
...shared,
description:
"Read this server's identity and selected environment preferences. Provider/model availability is exposed by orchestrator_capabilities. Writing instructions are limited to 4,000 characters.",
success: Schema.Struct({
environmentId: ExecutionEnvironmentDescriptor.fields.environmentId,
label: Schema.String,
serverVersion: Schema.String,
platform: ExecutionEnvironmentDescriptor.fields.platform,
preferences: Schema.Struct(PreferenceFields),
}),
})
.annotate(Tool.Readonly, true)
.annotate(Tool.Destructive, false);
export const EnvironmentPreferencesTool = Tool.make("t3_environment_preferences_update", {
...shared,
description:
"Update selected environment-wide preferences through normal settings persistence and notifications. Requires a live full-access/default calling thread. Omitted fields are preserved; empty customInstructions clears them.",
parameters: Schema.Struct({
defaultThreadEnvMode: ServerSettingsPatch.fields.defaultThreadEnvMode,
newWorktreesStartFromOrigin: ServerSettingsPatch.fields.newWorktreesStartFromOrigin,
enableProviderUpdateChecks: ServerSettingsPatch.fields.enableProviderUpdateChecks,
backgroundActivity: Schema.optionalKey(Schema.Struct({ profile: BackgroundActivityProfile })),
sourceControlWritingStyle: ServerSettingsPatch.fields.sourceControlWritingStyle,
}),
success: Schema.Struct(PreferenceFields),
}).annotate(Tool.Destructive, true);
export const EnvironmentToolkit = Toolkit.make(EnvironmentReadTool, EnvironmentPreferencesTool);
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeComp
import { attachmentRelativePath } from "../../attachmentStore.ts";
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools.ts";
import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts";
import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts";
import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts";
import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";
Expand Down Expand Up @@ -585,6 +586,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => {
const readOnlyToolNames = [
...Object.values(OrchestratorToolkit.tools),
...Object.values(ThreadToolkit.tools),
...Object.values(EnvironmentToolkit.tools),
...Object.values(PreviewControlsToolkit.tools),
]
.filter((tool) => Context.get(tool.annotations, Tool.Readonly))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray<string> = [
"mcp__t3-code__t3_thread_configuration",
"mcp__t3-code__t3_thread_transfers",
"mcp__t3-code__t3_preview_list",
"mcp__t3-code__t3_environment_read",
"mcp__t3-code__t3_queue_list",
"mcp__t3-code__t3_queue_read",
];
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/t3McpToolPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ const T3_MCP_TOOLS: Record<
t3_worktree_status: { displayName: "Get thread worktree status" },
t3_preview_list: { displayName: "List preview tabs" },
t3_preview_close: { displayName: "Close a preview tab" },
t3_environment_read: { displayName: "Read environment preferences" },
t3_environment_preferences_update: { displayName: "Update environment preferences" },
preview_status: { displayName: "Get preview browser status" },
preview_open: { displayName: "Open a page in the preview browser" },
preview_navigate: { displayName: "Navigate the preview browser" },
Expand Down
Loading