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
6 changes: 6 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import { SettingsNavigationRouteScreen } from "./features/settings/SettingsNavig
import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen";
import { SettingsNativeToolsRouteScreen } from "./features/settings/SettingsNativeToolsRouteScreen";
import { SettingsProviderAccountsRouteScreen } from "./features/settings/SettingsProviderAccountsRouteScreen";
import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
import { SettingsEnvironmentDetailRouteScreen } from "./features/settings/SettingsEnvironmentDetailRouteScreen";
Expand Down Expand Up @@ -202,6 +203,11 @@ const SETTINGS_SCREENS = {
linking: "settings/labs",
options: settingsPage("Labs"),
}),
SettingsNativeTools: createNativeStackScreen({
screen: SettingsNativeToolsRouteScreen,
linking: "settings/tools",
options: settingsPage("T3 tools"),
}),
SettingsProviderAccounts: createNativeStackScreen({
screen: SettingsProviderAccountsRouteScreen,
linking: "settings/provider-accounts",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { AuthSettingsWriteScope, isManagedSettingPath } from "@t3tools/contracts";
import { useState } from "react";
import { TextInput, View } from "react-native";
import { AppText as Text } from "../../components/ui/AppText";
import { ScreenScrollView } from "../../components/ui/ScreenScrollView";
import { serverEnvironment } from "../../state/server";
import { useEnvironmentsWithScope } from "../../state/session";
import { useAtomCommand } from "../../state/use-atom-command";
import { useSettingsEnvironmentMenu } from "./components/SettingsEnvironmentFilterHeader";
import { SettingsScreen } from "./components/SettingsScreen";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
import { useSettingsEnvironmentFilter } from "./settings-environment-filter";

export function SettingsNativeToolsRouteScreen() {
const { selectedTargets } = useSettingsEnvironmentFilter();
const environmentMenu = useSettingsEnvironmentMenu();
const writable = useEnvironmentsWithScope(selectedTargets, AuthSettingsWriteScope);
const update = useAtomCommand(serverEnvironment.updateSettings, {
label: "T3 tool settings update",
reportFailure: true,
});
const [search, setSearch] = useState("");
const query = search.trim().toLowerCase();
return (
<SettingsScreen title="T3 tools" menus={[environmentMenu]}>
<ScreenScrollView
className="flex-1"
contentInsetAdjustmentBehavior="automatic"
contentContainerClassName="gap-6 px-5 py-4"
>
<Text className="text-sm text-foreground-muted">
Choose which built-in T3 tools agents can use across all providers. Disabled tools block
new calls immediately. Reconnect existing agent sessions to refresh their tool lists.
Calls already running are not cancelled.
</Text>
<TextInput
accessibilityLabel="Search T3 tools"
placeholder="Search tools, e.g. t3_thread_send"
value={search}
onChangeText={setSearch}
autoCapitalize="none"
autoCorrect={false}
className="rounded-xl border border-border p-3 text-foreground"
/>
{selectedTargets.length === 0 ? (
<Text className="text-foreground-muted">Select a connected environment.</Text>
) : (
selectedTargets.map((environment) => {
const tools = environment.serverConfig.nativeTools;
const visible = tools?.filter((tool) =>
`${tool.name} ${tool.description}`.toLowerCase().includes(query),
);
return (
<SettingsSection key={environment.environmentId} title={environment.label}>
{tools === undefined ? (
<Text className="p-4 text-foreground-muted">
Update this environment to manage T3 tools.
</Text>
) : visible?.length === 0 ? (
<Text className="p-4 text-foreground-muted">No matching tools.</Text>
) : (
visible?.map((tool) => (
<View key={tool.name}>
<SettingsSwitchRow
label={tool.name}
subtitle={tool.description}
value={
environment.serverConfig.settings.nativeToolEnabled[tool.name] !== false
}
disabled={
!writable.has(environment.environmentId) ||
isManagedSettingPath(
environment.serverConfig.managedPolicy,
`nativeToolEnabled.${tool.name}`,
)
}
onValueChange={(enabled) => {
void update({
environmentId: environment.environmentId,
input: { patch: { nativeToolEnabled: { [tool.name]: enabled } } },
});
}}
/>
</View>
))
)}
</SettingsSection>
);
})
)}
</ScreenScrollView>
</SettingsScreen>
);
}
1 change: 1 addition & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ function useSettingsIndexSections(): SettingsListSection[] {
title: "Agents",
rows: [
serverPage("SettingsProviderAccounts", "person.crop.circle", "Providers"),
serverPage("SettingsNativeTools", "gearshape", "T3 tools"),
serverPage("SettingsThreads", "text.bubble", "Threads"),
page("SettingsScheduledTasks", "clock", "Scheduled tasks"),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const SETTINGS_PAGE_TARGETS = [
"SettingsLabs",
"SettingsEnvironmentMaintenance",
"SettingsProviderAccounts",
"SettingsNativeTools",
"SettingsKeyboard",
"SettingsVoice",
"SettingsFollowUp",
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/settings/settings-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const SETTINGS_SEARCH_TERMS = {
],
SettingsFollowUp: ["follow-ups", "queue", "steer", "while the agent is running"],
SettingsArchive: ["archive", "archived", "restore", "unarchive"],
SettingsNativeTools: ["native tools", "MCP", "enable", "disable", "t3_thread_send"],
SettingsProviderAccounts: [
"provider accounts",
"codex",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/device/DeviceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ const fixture = Effect.fn("fixture")(function* (
withSettingsSnapshot: (use) => Effect.flatMap(Ref.get(settings), use),
start: Effect.void,
ready: Effect.void,
getMcpToolSettings: Ref.get(settings),
getSettings: Ref.get(settings),
updateSettings: (patch) =>
Ref.updateAndGet(settings, (current) => ({
Expand Down
158 changes: 152 additions & 6 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// @effect-diagnostics-next-line nodeBuiltinImport:off -- Bind the test listener to the same address family as its client.
import * as NodeHttp from "node:http";
import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
Expand All @@ -21,6 +23,8 @@ import * as Context from "effect/Context";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as FetchHttpClient from "effect/http/FetchHttpClient";
import * as HttpServer from "effect/http/HttpServer";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -53,6 +57,16 @@ import { OrchestratorToolkit } from "./toolkits/orchestrator/tools.ts";
import { PreviewToolkit } from "./toolkits/preview/tools.ts";
import { ProjectToolkit } from "./toolkits/project/tools.ts";

// A wildcard listener can share an IPv4-only app's port on macOS.
const layerHttpTest = HttpServer.layerTestClient.pipe(
Layer.provide(
Layer.fresh(FetchHttpClient.layer).pipe(
Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ keepalive: false })),
),
),
Layer.provideMerge(NodeHttpServer.layer(NodeHttp.createServer, { host: "127.0.0.1", port: 0 })),
);

const environmentId = EnvironmentId.make("environment-mcp-test");
const threadId = ThreadId.make("thread-mcp-test");
const alternateTabId = PreviewTabId.make("tab-mcp-alternate");
Expand Down Expand Up @@ -916,7 +930,7 @@ it.effect("terminates HTTP MCP sessions with DELETE", () =>
});
expect(reusedSessionResponse.status).toBe(404);
}),
).pipe(Effect.provide(NodeHttpServer.layerTest)),
).pipe(Effect.provide(layerHttpTest)),
);

it.effect("admits provider and OAuth client credentials and points only clients at OAuth", () =>
Expand Down Expand Up @@ -1069,7 +1083,7 @@ it.effect("admits provider and OAuth client credentials and points only clients
),
).toEqual([]);
}),
).pipe(Effect.provide(NodeHttpServer.layerTest)),
).pipe(Effect.provide(layerHttpTest)),
);

it.effect("accepts case-insensitive MCP bearer schemes and diagnoses rejected credentials", () => {
Expand Down Expand Up @@ -1165,7 +1179,7 @@ it.effect("accepts case-insensitive MCP bearer schemes and diagnoses rejected cr
).pipe(
Effect.provide(
layerAuth.pipe(
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(layerHttpTest),
Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false })),
),
),
Expand Down Expand Up @@ -1283,6 +1297,14 @@ it.effect("hides tools for links and refuses another environment while the beta
});
expect((yield* ranIn(environmentId)).structuredContent).toEqual({ ranIn: "here" });

yield* settings.updateSettings({ nativeToolEnabled: { where: false, links: true } });
expect(yield* listed()).toEqual([]);
const denied = yield* rpc("tools/call", { name: "where", arguments: { environmentId } });
expect(denied).toMatchObject({ error: { code: -32602 } });
yield* settings.updateSettings({ nativeToolEnabled: { where: true } });
expect(yield* listed()).toEqual(["where"]);
expect((yield* ranIn(environmentId)).structuredContent).toEqual({ ranIn: "here" });

yield* settings.updateSettings({ linkedEnvironmentsEnabled: true });
expect(yield* listed()).toEqual(["links", "where"]);
// On, the call goes on to the forwarding, which this server lacks.
Expand All @@ -1291,7 +1313,131 @@ it.effect("hides tools for links and refuses another environment while the beta
content: [{ text: expect.stringContaining("cannot reach linked environments") }],
});
}),
).pipe(Effect.provide(NodeHttpServer.layerTest)),
).pipe(Effect.provide(layerHttpTest)),
);

it.effect.each(["stored", "managed", "malformed"] as const)(
"enforces native tool policy without resolving provider secrets (%s)",
(mode) => {
const layerSettings = ServerSettings.layer.pipe(
Layer.provide(
Layer.mock(ServerSecretStore.ServerSecretStore)({
get: (resource) =>
Effect.fail(
new ServerSecretStore.SecretStoreReadError({
resource,
cause: new Error("Secret store unavailable."),
}),
),
}),
),
Layer.provide(
ManagedPolicy.layerTest(
mode === "managed"
? {
schemaVersion: 1,
organization: "Example Corp",
settings: { "nativeToolEnabled.disabled": { value: false, locked: true } },
}
: undefined,
),
),
Layer.provideMerge(Sqlite.layerMemory),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-tool-policy-test-" }),
),
);
return Effect.scoped(
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
yield* fs.writeFileString(
config.settingsPath,
mode === "malformed"
? '{"nativeToolEnabled":'
: JSON.stringify({
nativeToolEnabled: { disabled: mode === "managed" },
providerInstances: {
codex: {
driver: "codex",
config: {},
environment: [
{ name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true },
],
},
},
}),
);
if (mode !== "malformed") {
const settings = yield* ServerSettings.ServerSettingsService;
expect((yield* settings.getSettings.pipe(Effect.flip)).operation).toBe("read-secret");
}
const Tools = Toolkit.make(
Tool.make("enabled", { success: Schema.String }),
Tool.make("disabled", { success: Schema.String }),
);
const serverLayer = McpHttpServer.layerMcpTransport(
McpHttpServer.toolkitRegistration(
Tools,
McpToolAccess.toLayer(Tools, {
enabled: McpToolAccess.reads(() => Effect.succeed("available")),
disabled: McpToolAccess.reads(() => Effect.die("Disabled tool ran.")),
}),
),
).pipe(
Layer.provide(McpToolAccessTestkit.liveThreadsLayer),
Layer.provide(
Layer.mock(McpSessionRegistry.McpSessionRegistry)({
resolve: () => Effect.succeed(invocation),
}),
),
);
yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe(
Layer.build,
);
const httpClient = yield* HttpClient.HttpClient;
const request = (method: string, params: object, sessionId?: string) =>
httpClient.post("/mcp", {
headers: {
accept: "application/json, text/event-stream",
authorization: "Bearer policy-test",
...(sessionId === undefined ? {} : { "mcp-session-id": sessionId }),
},
body: HttpBody.text(
JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
"application/json",
),
});
const initialized = yield* request("initialize", {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "policy-test", version: "1.0.0" },
});
expect(initialized.status).toBe(mode === "malformed" ? 500 : 200);
if (mode === "malformed") return;
const rpc = (method: string, params: object) =>
request(method, params, initialized.headers["mcp-session-id"]!).pipe(
Effect.flatMap((response) => response.json),
);
expect(yield* rpc("tools/list", {})).toMatchObject({
result: { tools: [{ name: "enabled" }] },
});
expect(yield* rpc("tools/call", { name: "enabled", arguments: {} })).toMatchObject({
result: { content: [{ type: "text", text: "available" }] },
});
expect(yield* rpc("tools/call", { name: "disabled", arguments: {} })).toMatchObject({
error: { code: -32602 },
});
}),
).pipe(
Effect.provide(
layerSettings.pipe(
Layer.provideMerge(layerHttpTest),
Layer.provideMerge(NodeServices.layer),
),
),
);
},
);

it.effect("lists pull request watch tools only to threads that are not delegated children", () =>
Expand Down Expand Up @@ -1381,7 +1527,7 @@ it.effect("lists pull request watch tools only to threads that are not delegated
expect(childTools).not.toContain("watch_pull_request");
expect(childTools).not.toContain("unwatch_pull_request");
}),
).pipe(Effect.provide(NodeHttpServer.layerTest)),
).pipe(Effect.provide(layerHttpTest)),
);

it.effect(
Expand Down Expand Up @@ -1526,5 +1672,5 @@ it.effect(
expect(yield* start("auto/default/extra")).toBe(400);
expect(yield* start("root/default")).toBe(400);
}),
).pipe(Effect.provide(NodeHttpServer.layerTest)),
).pipe(Effect.provide(layerHttpTest)),
);
Loading
Loading