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
8 changes: 7 additions & 1 deletion apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ 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 * as ProviderUsageLimitsMcpService from "./ProviderUsageLimitsMcpService.ts";
import * as UsageLimitsService from "../usage/UsageLimitsService.ts";
import * as EnvironmentHandlers from "./toolkits/environment/handlers.ts";
import { ProjectToolkit } from "./toolkits/project/tools.ts";
import * as ProjectHandlers from "./toolkits/project/handlers.ts";
Expand Down Expand Up @@ -862,4 +864,8 @@ export const layer = Layer.mergeAll(
layerPullRequestsToolkit,
layerDeviceToolkit,
layerHtmlToolkit,
).pipe(Layer.provideMerge(layerMcpTransport));
).pipe(
Layer.provide(ProviderUsageLimitsMcpService.layer),
Layer.provide(UsageLimitsService.layer),
Layer.provideMerge(layerMcpTransport),
);
39 changes: 39 additions & 0 deletions apps/server/src/mcp/ProviderUsageLimitsMcpService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { OrchestratorMcpFailure, type ProviderUsageLimitsMcpResult } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
import * as UsageLimitsService from "../usage/UsageLimitsService.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
import { readCaller } from "./threadAccess.ts";

export class ProviderUsageLimitsMcpService extends Context.Service<
ProviderUsageLimitsMcpService,
{
readonly read: Effect.Effect<
ProviderUsageLimitsMcpResult,
OrchestratorMcpFailure,
McpInvocationContext.McpInvocationContext | ThreadManagementService.ThreadManagementService
>;
}
>()("t3/mcp/ProviderUsageLimitsMcpService") {}

const make = Effect.gen(function* () {
const environment = yield* ServerEnvironment.ServerEnvironment;
const usageLimits = yield* UsageLimitsService.UsageLimitsService;
const read = Effect.gen(function* () {
const caller = yield* readCaller();
const descriptor = yield* environment.getDescriptor;
if (descriptor.environmentId !== caller.scope.environmentId)
return yield* new OrchestratorMcpFailure({
code: "capability_denied",
message: "This credential belongs to another environment.",
});
return yield* usageLimits.read;
});
return ProviderUsageLimitsMcpService.of({ read });
});

export const layer = Layer.effect(ProviderUsageLimitsMcpService, make);
59 changes: 59 additions & 0 deletions apps/server/src/mcp/toolkits/environment/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import { liveThreadShell } from "../../McpToolAccess.testkit.ts";
import * as EnvironmentHandlers from "./handlers.ts";
import { EnvironmentToolkit } from "./tools.ts";

import * as ProviderUsageLimitsMcpService from "../../ProviderUsageLimitsMcpService.ts";
import * as UsageLimitsService from "../../../usage/UsageLimitsService.ts";

const environmentId = EnvironmentId.make("environment:preferences");
const threadId = ThreadId.make("thread:preferences");

Expand All @@ -33,6 +36,7 @@ it.effect("refuses a preferences update when the caller's turn ends while it wai
// Completes once the declaration's own check has read the caller.
const checked = yield* Deferred.make<void>();
const layerDependencies = Layer.mergeAll(
Layer.mock(ProviderUsageLimitsMcpService.ProviderUsageLimitsMcpService)({}),
ThreadCommandExecutor.layer,
Layer.succeed(McpInvocationContext.McpInvocationContext, {
environmentId,
Expand Down Expand Up @@ -98,3 +102,58 @@ it.effect("refuses a preferences update when the caller's turn ends while it wai
);
}),
);

it.effect.each(["read-only", "wrong-environment", "missing-capability"] as const)(
"quota read handles %s callers",
(scenario) =>
Effect.gen(function* () {
const report = { readAt: "1970-01-01T00:00:00.000Z", providers: [], sources: [] };
const dependencies = Layer.mergeAll(
Layer.succeed(McpInvocationContext.McpInvocationContext, {
environmentId:
scenario === "wrong-environment" ? EnvironmentId.make("other") : environmentId,
requestNamespace: "client:quota",
thread: undefined,
client: { sessionId: "quota", label: "Quota client", access: "read-only" },
capabilities: new Set(
scenario === "missing-capability" ? [] : ["orchestration" as const],
),
issuedAt: 0,
}),
Layer.mock(ThreadManagement.ThreadManagementService)({}),
Layer.mock(Environment.ServerEnvironment)({
getDescriptor: Effect.succeed({
environmentId,
label: "Test",
platform: { os: "linux", arch: "x64" },
serverVersion: "0.0.0",
capabilities: { repositoryIdentity: false },
}),
}),
Layer.mock(Settings.ServerSettingsService)({}),
ThreadCommandExecutor.layer,
Layer.mock(UsageLimitsService.UsageLimitsService)({
read:
scenario === "read-only"
? Effect.succeed(report)
: Effect.die("unauthorized quota read"),
}),
);
const result = yield* Effect.gen(function* () {
const toolkit = yield* EnvironmentToolkit;
return yield* toolkit
.handle("t3_provider_usage_limits", {})
.pipe(Stream.unwrap, Stream.runCollect);
}).pipe(
Effect.provide(
McpToolAccess.HandlersLayer.layer(EnvironmentHandlers.layer).pipe(
Layer.provideMerge(ProviderUsageLimitsMcpService.layer),
Layer.provideMerge(dependencies),
),
),
);
expect(result.at(-1)?.result).toMatchObject(
scenario === "read-only" ? report : { code: "capability_denied" },
);
}),
);
7 changes: 7 additions & 0 deletions apps/server/src/mcp/toolkits/environment/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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 ProviderUsageLimitsMcpService from "../../ProviderUsageLimitsMcpService.ts";
import * as Settings from "../../../serverSettings.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
Expand Down Expand Up @@ -29,6 +30,7 @@ export function preferences(settings: ServerSettings) {
},
};
}

const access = Effect.gen(function* () {
const context = yield* readCaller();
const environment = yield* Environment.ServerEnvironment;
Expand All @@ -41,6 +43,11 @@ const access = Effect.gen(function* () {
return { ...context, descriptor, settings: yield* Settings.ServerSettingsService };
});
export const layer = McpToolAccess.toLayer(EnvironmentToolkit, {
t3_provider_usage_limits: McpToolAccess.reads(() =>
Effect.gen(function* () {
return yield* (yield* ProviderUsageLimitsMcpService.ProviderUsageLimitsMcpService).read;
}),
),
t3_environment_read: McpToolAccess.reads(() =>
Effect.gen(function* () {
const { descriptor, settings } = yield* access;
Expand Down
25 changes: 24 additions & 1 deletion apps/server/src/mcp/toolkits/environment/tools.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ProviderUsageLimitsMcpResult,
BackgroundActivityProfile,
BackgroundActivityProfileSelection,
ExecutionEnvironmentDescriptor,
Expand All @@ -14,6 +15,8 @@ import * as ThreadManagementService from "../../../orchestration-v2/ThreadManage
import * as Settings from "../../../serverSettings.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";

import * as ProviderUsageLimitsMcpService from "../../ProviderUsageLimitsMcpService.ts";

const PreferenceFields = {
defaultThreadEnvMode: ServerSettings.fields.defaultThreadEnvMode,
newWorktreesStartFromOrigin: ServerSettings.fields.newWorktreesStartFromOrigin,
Expand Down Expand Up @@ -64,4 +67,24 @@ const EnvironmentPreferencesTool = Tool.make("t3_environment_preferences_update"
}),
success: Schema.Struct(PreferenceFields),
}).annotate(Tool.Destructive, true);
export const EnvironmentToolkit = Toolkit.make(EnvironmentReadTool, EnvironmentPreferencesTool);
const ProviderUsageLimitsTool = Tool.make("t3_provider_usage_limits", {
description:
"Read cached usage windows and remaining percentages for every provider instance and usage-source account in this environment. Does not probe or refresh. checkedAt and ageSeconds describe freshness; expired reset timestamps do not imply quota has been restored. Missing quota is not zero or unlimited. No credentials or raw errors are returned.",
success: ProviderUsageLimitsMcpResult,
failure: OrchestratorMcpFailure,
failureMode: "return",
dependencies: [
McpInvocationContext.McpInvocationContext,
ThreadManagementService.ThreadManagementService,
ServerEnvironment.ServerEnvironment,
ProviderUsageLimitsMcpService.ProviderUsageLimitsMcpService,
],
})
.annotate(Tool.Readonly, true)
.annotate(Tool.Destructive, false);

export const EnvironmentToolkit = Toolkit.make(
EnvironmentReadTool,
EnvironmentPreferencesTool,
ProviderUsageLimitsTool,
);
7 changes: 7 additions & 0 deletions apps/server/src/mcp/toolkits/worktree/registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,15 @@ import * as McpSessionRegistry from "../../McpSessionRegistry.ts";
import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
import * as PreviewBrowser from "../../../preview/PreviewBrowser.ts";

import * as UsageLimitSources from "../../../usage/UsageLimitSources.ts";

const layerStubServices = Layer.mergeAll(
Layer.mock(Orchestrator.OrchestratorV2)({}),
Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
Layer.mock(DeviceService.DeviceService)({}),
Layer.mock(ThreadManagementService.ThreadManagementService)({}),
Layer.mock(ProviderRegistry.ProviderRegistry)({}),
Layer.mock(UsageLimitSources.UsageLimitSources)({}),
Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Expand Down Expand Up @@ -139,6 +142,10 @@ it.effect("production mcp layer lists worktree tools over http", () =>
expect(status?.annotations?.readOnlyHint).toBe(true);
expect(status?.annotations?.destructiveHint).toBe(false);

const quota = tools.find((tool) => tool.name === "t3_provider_usage_limits");
expect(quota?.annotations?.readOnlyHint).toBe(true);
expect(quota?.annotations?.destructiveHint).toBe(false);

// MCP requires every tool input schema to be a top-level object schema.
// A non-object schema (e.g. the anyOf produced by an empty
// Schema.Struct({})) makes clients reject the entire server.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray<string> = [
"mcp__t3-code__t3_thread_search",
"mcp__t3-code__t3_preview_list",
"mcp__t3-code__t3_environment_read",
"mcp__t3-code__t3_provider_usage_limits",
"mcp__t3-code__t3_queue_list",
"mcp__t3-code__t3_queue_read",
"mcp__t3-code__html_preview",
Expand Down
Loading
Loading