Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
0b19d51
feat: manage skills and MCP servers in Settings → Tools
juliusmarminge Oct 8, 2026
54f5ab7
chore: drop unused exports flagged by knip
juliusmarminge Oct 9, 2026
1038f87
Merge origin/main into t3code/agent-tools-settings
juliusmarminge Oct 9, 2026
22c016b
fix(server): pass skill switches to Claude's query
juliusmarminge Oct 9, 2026
a9239e6
fix: address review findings on Settings → Tools
juliusmarminge Oct 9, 2026
8e7f082
fix(server): read the host platform through its injected reference
juliusmarminge Oct 9, 2026
79912b2
fix(server): keep MCP server secrets out of Claude's process arguments
juliusmarminge Oct 9, 2026
f1f98fb
fix: keep both secrets when a rename swaps two names, and group repo …
juliusmarminge Oct 9, 2026
35a5d4f
fix(server): map a repeated MCP variable name to one Claude secret
juliusmarminge Oct 9, 2026
9f0fa23
fix(server): ask before Claude uses a Settings → Tools server in mode…
juliusmarminge Oct 9, 2026
6396c81
feat(server): install skills from a source with the bundled skills CLI
juliusmarminge Oct 10, 2026
458f630
feat: add skills from a source, view their files, and let agents chan…
juliusmarminge Oct 10, 2026
ec2b475
fix: address review findings on the skills installer
juliusmarminge Oct 10, 2026
64303a5
fix: run skill changes one at a time, and keep the environment's spel…
juliusmarminge Oct 10, 2026
28399ce
fix: rescan listed projects after a global skill change, and handle a…
juliusmarminge Oct 10, 2026
58f6a82
fix: scan one folder's agents in turn, and ignore inherited server names
juliusmarminge Oct 10, 2026
bd9994d
feat(web): Skills list and skill page in the style of #17513
juliusmarminge Oct 11, 2026
b26a78d
fix(server): install and track skills by the skills CLI's own name
juliusmarminge Oct 11, 2026
7425688
fix: cap a skill install at 200 skills, and read large installer resu…
juliusmarminge Oct 11, 2026
b0c21cb
fix(server): flush the skills installer's output by making stdout blo…
juliusmarminge Oct 11, 2026
57d3b32
fix(web): name an open skill in the settings header, not above the page
juliusmarminge Oct 11, 2026
efe7aa6
fix(web): show project skills only for the project picked, and line u…
juliusmarminge Oct 11, 2026
17163ab
fix(web): mark skill packs with a heading band instead of an indent
juliusmarminge Oct 11, 2026
2ea0651
fix(web): put the Tools tabs on one row with each tab's controls
juliusmarminge Oct 11, 2026
6cda7c8
fix(web): flag only skills that share a name, not ones some agents skip
juliusmarminge Oct 11, 2026
88b1ea3
fix(web): pick skills to add from dense rows, with select all and a f…
juliusmarminge Oct 11, 2026
4ae4726
fix(web): show a skill's whole description when its line is cut off
juliusmarminge Oct 11, 2026
57758db
feat: remove any skill in the agents' own skill folders
juliusmarminge Oct 11, 2026
8ddae4a
fix(web): copy a skill's path from a button beside it
juliusmarminge Oct 11, 2026
93548e8
fix(web): show a skill's Update and Remove as buttons, not a menu
juliusmarminge Oct 11, 2026
b6fe2fa
docs: a skill's page shows its files, there is no View files menu item
juliusmarminge Oct 11, 2026
57ce0cf
fix(web): list T3 Code's own MCP server, always on
juliusmarminge Oct 11, 2026
d2758ad
feat: show which environments have each skill, and copy one to the rest
juliusmarminge Oct 11, 2026
6a48246
feat(server): list an MCP server's tools from Settings → Tools
juliusmarminge Oct 11, 2026
25a7af8
feat(web): open an MCP server in Settings → Tools to see its tools
juliusmarminge Oct 11, 2026
88a827b
chore(web): drop an unused import left by combining the Tools changes
juliusmarminge Oct 11, 2026
651e928
Merge upstream skills installer and Tools settings (#17691)
riccardovandra Oct 23, 2026
2d37cd7
Merge remote-tracking branch 'origin/main' into feat/global-skills-port
riccardovandra Oct 23, 2026
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
3 changes: 2 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
"@ff-labs/fff-node": "0.11.0",
"@modelcontextprotocol/sdk": "1.29.0",
"@muse-code/sdk": "1.4.6",
"@napi-rs/keyring": "^1.3.0",
"@opencode-ai/sdk": "^1.3.15",
Expand All @@ -60,6 +61,7 @@
"node-pty": "^1.2.0-beta.15",
"playwright-core": "1.60.0",
"proper-lockfile": "4.1.2",
"skills": "1.7.1",
"stream-chain": "^4.2.5",
"stream-json": "3.6.0",
"undici": "catalog:",
Expand All @@ -69,7 +71,6 @@
"devDependencies": {
"@effect/graphql-generator": "catalog:",
"@effect/vitest": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@t3tools/contracts": "workspace:*",
"@t3tools/provider-testing": "workspace:*",
"@t3tools/shared": "workspace:*",
Expand Down
56 changes: 56 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
AuthProvidersManageScope,
AuthSettingsWriteScope,
DEFAULT_SERVER_SETTINGS,
ProjectId,
ProviderInstanceId,
ThreadId,
AuthOrchestrationOperateScope,
Expand Down Expand Up @@ -219,6 +220,61 @@ describe("settings mutation authorization", () => {
}).pipe(Effect.scoped),
);

it.effect("lets a settings grant resend a project's stored MCP server, not change it", () =>
Effect.gen(function* () {
const projectId = ProjectId.make("project-tools");
const linear = {
enabled: true,
transport: { type: "http" as const, url: "https://mcp.linear.app/mcp", headers: [] },
};
let handled = 0;
const client = yield* RpcTest.makeClient(group).pipe(
Effect.provide(
Layer.mergeAll(
group.toLayerHandler(WS_METHODS.serverUpdateSettings, () =>
Effect.sync(() => {
handled++;
return DEFAULT_SERVER_SETTINGS;
}),
),
RpcAuthorization.layer(
[AuthSettingsWriteScope],
Effect.succeed({
projectSettingsOverrides: { [projectId]: { mcpServers: { linear } } },
}),
),
),
),
);
// Turning a skill off resends the project's server exactly as stored.
yield* client[WS_METHODS.serverUpdateSettings]({
patch: {
projectSettingsOverrides: {
[projectId]: { mcpServers: { linear }, disabledSkills: { "grill-me": true } },
},
},
});
expect(handled).toBe(1);
expect(
yield* client[WS_METHODS.serverUpdateSettings]({
patch: {
projectSettingsOverrides: {
[projectId]: {
mcpServers: {
linear: {
...linear,
transport: { ...linear.transport, url: "https://example.com/mcp" },
},
},
},
},
},
}).pipe(Effect.flip),
).toMatchObject({ requiredPermission: AuthProvidersManageScope });
expect(handled).toBe(1);
}).pipe(Effect.scoped),
);

it.effect("does not let a settings grant create or remove providers", () =>
Effect.gen(function* () {
let handled = false;
Expand Down
51 changes: 40 additions & 11 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
AssetCreateUrlInput,
AuthAccessReadScope,
ServerSettingsPatch,
type ServerSettings,
ProviderInstanceMutation,
requiredScopesForServerSettingsPatch,
requiredScopesForProjectMutation,
Expand Down Expand Up @@ -154,6 +155,16 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope,
// The server lists skill folders itself; a client never names a path.
[WS_METHODS.skillsInspect]: AuthFilesystemReadScope,
// A preview unpacks a source into a temp folder and deletes it.
[WS_METHODS.skillsPreview]: AuthFilesystemReadScope,
// Packs a folder an agent reports for that skill, nothing a client names.
[WS_METHODS.skillsExport]: AuthFilesystemReadScope,
// Listing a server's tools runs its command with its stored secrets, which
// only a connection allowed to add that command may do. A read the client
// caches as a query, so the client checks the scope itself (no guard entry).
[WS_METHODS.mcpServersListTools]: AuthProvidersManageScope,
[WS_METHODS.projectsListEntries]: AuthFilesystemReadScope,
[WS_METHODS.projectsListIgnoredFiles]: AuthFilesystemReadScope,
[WS_METHODS.projectsReadFile]: AuthFilesystemReadScope,
Expand Down Expand Up @@ -245,9 +256,12 @@ const SettingsUpdate = Schema.Struct({
providerInstanceMutation: Schema.optionalKey(ProviderInstanceMutation),
});

const requiredScopesForSettingsUpdate = (payload: unknown) => {
const requiredScopesForSettingsUpdate = (
payload: unknown,
current: Pick<ServerSettings, "projectSettingsOverrides"> | undefined,
) => {
const input = Schema.decodeUnknownSync(SettingsUpdate)(payload);
const scopes = requiredScopesForServerSettingsPatch(input.patch);
const scopes = requiredScopesForServerSettingsPatch(input.patch, current);
if (input.providerInstanceMutation === undefined) return scopes;
// An atomic provider mutation carries an empty patch unless it also changes settings.
return Object.values(input.patch).every((value) => value === undefined)
Expand All @@ -260,6 +274,7 @@ const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation);
const requiredScopesForRpcCall = (
method: string,
payload: unknown,
currentSettings?: Pick<ServerSettings, "projectSettingsOverrides">,
): ReadonlyArray<AuthEnvironmentScope> => {
if (method === WS_METHODS.serverRetryResourceTelemetry) {
return [AuthEnvironmentMaintainScope, AuthDiagnosticsReadScope];
Expand All @@ -274,7 +289,9 @@ const requiredScopesForRpcCall = (
: AuthOrchestrationReadScope,
];
}
if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload);
if (method === WS_METHODS.serverUpdateSettings) {
return requiredScopesForSettingsUpdate(payload, currentSettings);
}
if (method === WS_METHODS.projectsMutate) {
return requiredScopesForProjectMutation(decodeProjectMutation(payload));
}
Expand All @@ -284,22 +301,34 @@ const requiredScopesForRpcCall = (
};

/**
* Authorizes every RPC on one connection against that connection's session scopes.
* `currentSettings` lets a settings update that only resends a project's stored
* MCP servers pass with `settings:write`.
*
* `onAuthorized` runs once after an authorized request succeeds; for a stream
* RPC, that is when the stream completes. Every RPC passes through here, so it
* is the one place to observe requests without wrapping each handler.
*/
export const layer = (
scopes: ReadonlyArray<AuthEnvironmentScope>,
currentSettings: Effect.Effect<
Pick<ServerSettings, "projectSettingsOverrides"> | undefined
> = Effect.succeed(undefined),
onAuthorized?: (method: string, payload: unknown) => Effect.Effect<void>,
) =>
Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) => {
const requiredScopes = requiredScopesForRpcCall(rpc._tag, payload);
const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope));
if (requiredScope !== undefined) return Effect.fail(rpcAuthorizationError(requiredScope));
return onAuthorized === undefined
? effect
: Effect.tap(effect, () => onAuthorized(rpc._tag, payload));
});
Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) =>
Effect.gen(function* () {
const settings =
rpc._tag === WS_METHODS.serverUpdateSettings ? yield* currentSettings : undefined;
const requiredScopes = requiredScopesForRpcCall(rpc._tag, payload, settings);
const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope));
if (requiredScope !== undefined)
return yield* Effect.fail(rpcAuthorizationError(requiredScope));
return yield* onAuthorized === undefined
? effect
: Effect.tap(effect, () => onAuthorized(rpc._tag, payload));
}),
);

/** Retrying can install or restart tools even though ordinary listing is readable. */
export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope =>
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ if (
} else if (command === "acp-mcp-bridge" || command === "acp-mcp-call") {
const { runAcpMcpCliFastPath } = await import("./mcp/AcpMcpStdioBridge.ts");
await runAcpMcpCliFastPath(command, process.argv.slice(3));
} else if (command === "skills-cli") {
// Option parsing belongs to the skills CLI, so it skips the T3 CLI entirely.
const { runSkillsCli } = await import("./cli/skillsCli.ts");
await runSkillsCli(process.argv.slice(3));
} else {
// Before anything reads stdin: the desktop app's Windows launcher starts
// the CLI without one. Never for the fast paths, whose stdin is a pipe.
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/binCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as CliError from "effect/cli/CliError";
import * as NetService from "@t3tools/shared/Net";
import packageJson from "../package.json" with { type: "json" };
import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts";
import { skillsCliCommand } from "./cli/skillsCli.ts";
import { authCommand } from "./cli/auth.ts";
import { appCommand } from "./cli/app.ts";
import { browserCommand } from "./cli/browser.ts";
Expand Down Expand Up @@ -70,6 +71,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
),
acpMcpBridgeCommand,
acpMcpCallCommand,
skillsCliCommand,
startCommand,
serveCommand,
appCommand,
Expand Down
33 changes: 33 additions & 0 deletions apps/server/src/cli/skillsCli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as Effect from "effect/Effect";
import { Argument, Command } from "effect/cli";

/**
* `t3 skills-cli <args…>` runs the bundled `skills` CLI (vercel-labs/skills),
* the installer `npx skills` runs. The server spawns it through this hidden
* subcommand so installs need no Node or npm of their own: the CLI is part of
* this build, and runs as a child so its `process.exit` and prompts stay out
* of the server.
*/
export async function runSkillsCli(args: ReadonlyArray<string>): Promise<void> {
// The CLI prints its `--json` result and calls `process.exit` straight
// away. Writes to a pipe are asynchronous on POSIX, so whatever hasn't
// flushed would be lost: a large result is cut at 64 KiB. A blocking pipe
// finishes each write before the next statement, so exit loses nothing.
// (Windows pipes are already synchronous.)
const stdout = process.stdout as typeof process.stdout & {
readonly _handle?: { readonly setBlocking?: (blocking: boolean) => void };
};
stdout._handle?.setBlocking?.(true);
// The CLI reads its command from argv[2] onward.
process.argv = [process.argv[0] ?? "node", "skills", ...args];
await import("skills/dist/cli.mjs");
}

/** Real invocations dispatch through the bin.ts fast path before the CLI graph loads. */
export const skillsCliCommand = Command.make("skills-cli", {
args: Argument.String("args").pipe(Argument.variadic()),
}).pipe(
Command.withDescription("Run the bundled skills installer."),
Command.unlisted,
Command.withHandler(({ args }) => Effect.promise(() => runSkillsCli(args))),
);
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 @@ -198,6 +198,7 @@ const fixture = Effect.fn("fixture")(function* (
ServerSettings.ServerSettingsService,
ServerSettings.ServerSettingsService.of({
updateProviderInstance: () => Effect.die("Unexpected provider mutation"),
updateSettingsWith: () => Effect.die("Unexpected settings update"),
withSettingsSnapshot: (use) => Effect.flatMap(Ref.get(settings), use),
start: Effect.void,
ready: Effect.void,
Expand Down
32 changes: 32 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts";
import * as PreviewControlsHandlers from "./toolkits/previewControls/handlers.ts";
import { EnvironmentToolkit } from "./toolkits/environment/tools.ts";
import { ToolsToolkit } from "./toolkits/tools/tools.ts";
import * as EnvironmentHandlers from "./toolkits/environment/handlers.ts";
import * as ToolsHandlers from "./toolkits/tools/handlers.ts";
import { ProjectToolkit } from "./toolkits/project/tools.ts";
import * as ProjectHandlers from "./toolkits/project/handlers.ts";
import { AttachmentToolkit } from "./toolkits/attachment/tools.ts";
Expand Down Expand Up @@ -1055,6 +1057,10 @@ const layerEnvironmentToolkit = toolkitRegistration(

const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer);

const layerToolsRegistration = toolkitRegistration(ToolsToolkit, ToolsHandlers.layer).pipe(
Layer.provide(ThreadCommandExecutor.layer),
);

export const layerAttachmentToolkit = toolkitRegistration(
AttachmentToolkit,
AttachmentHandlers.layer,
Expand Down Expand Up @@ -1148,13 +1154,39 @@ export const layerMcpTransport = McpServer.layerHttp({
allowSessionTermination: true,
}).pipe(Layer.provide(layerMcpAuthMiddleware));

/**
* Every tool `layer` registers, for describing `t3-code` without a session
* (Settings → Tools). Add a toolkit here when you register one below.
*/
export const T3_MCP_TOOLKITS: ReadonlyArray<Toolkit.Any> = [
PreviewStandardToolkit,
PreviewSnapshotToolkit,
OrchestratorToolkit,
ThreadToolkit,
AttachmentToolkit,
ProjectToolkit,
EnvironmentToolkit,
ToolsToolkit,
PreviewControlsToolkit,
WorktreeToolkit,
PullRequestsToolkit,
DeviceStandardToolkit,
DeviceScreenshotToolkit,
HtmlRenderToolkit,
HtmlPreviewToolkit,
DocsToolkit,
AipmToolkit,
McpServersToolkit,
];

export const layer = Layer.mergeAll(
layerPreviewToolkit,
layerOrchestratorToolkit,
layerThreadToolkit,
layerAttachmentToolkit,
layerProjectRegistration,
layerEnvironmentToolkit,
layerToolsRegistration,
layerPreviewControlsRegistration,
layerWorktreeToolkitRegistration,
layerPullRequestsToolkit,
Expand Down
27 changes: 27 additions & 0 deletions apps/server/src/mcp/resolveAgentTools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ProjectId, ResolvedMcpServer, ServerSettings } from "@t3tools/contracts";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";

import type { McpProviderSessionTools } from "@t3tools/provider-core/server/mcpSession";

/**
* A thread's tools from settings whose secrets are already materialized:
* the project's overrides applied, disabled servers dropped. The fingerprint
* covers the full config so a rotated secret also counts as a change; it
* stays on the server and is only ever compared, never sent or logged.
*/
export function resolveAgentTools(
settings: ServerSettings,
projectId: ProjectId | null,
): McpProviderSessionTools {
const effective = resolveProjectSettings(settings, projectId).settings;
const servers: ResolvedMcpServer[] = Object.entries(effective.mcpServers)
.filter(([, server]) => server.enabled)
.map(([name, server]) => ({ name, transport: server.transport }))
.toSorted((left, right) => left.name.localeCompare(right.name));
const disabledSkills = [...new Set(effective.disabledSkills)].toSorted();
const fingerprint =
servers.length === 0 && disabledSkills.length === 0
? ""
: JSON.stringify({ servers, disabledSkills });
return { servers, disabledSkills, fingerprint };
}
Loading