diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index f4da6e051a7c..96af9a173c58 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -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"; @@ -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), +); diff --git a/apps/server/src/mcp/ProviderUsageLimitsMcpService.ts b/apps/server/src/mcp/ProviderUsageLimitsMcpService.ts new file mode 100644 index 000000000000..eb7910174810 --- /dev/null +++ b/apps/server/src/mcp/ProviderUsageLimitsMcpService.ts @@ -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); diff --git a/apps/server/src/mcp/toolkits/environment/handlers.test.ts b/apps/server/src/mcp/toolkits/environment/handlers.test.ts index e4ae660cc004..cf4c5cf05f51 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.test.ts @@ -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"); @@ -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(); const layerDependencies = Layer.mergeAll( + Layer.mock(ProviderUsageLimitsMcpService.ProviderUsageLimitsMcpService)({}), ThreadCommandExecutor.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { environmentId, @@ -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" }, + ); + }), +); diff --git a/apps/server/src/mcp/toolkits/environment/handlers.ts b/apps/server/src/mcp/toolkits/environment/handlers.ts index d7b84ca0599c..9e10d6a25645 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.ts @@ -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"; @@ -29,6 +30,7 @@ export function preferences(settings: ServerSettings) { }, }; } + const access = Effect.gen(function* () { const context = yield* readCaller(); const environment = yield* Environment.ServerEnvironment; @@ -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; diff --git a/apps/server/src/mcp/toolkits/environment/tools.ts b/apps/server/src/mcp/toolkits/environment/tools.ts index e441174900ce..7bc78e2a22ee 100644 --- a/apps/server/src/mcp/toolkits/environment/tools.ts +++ b/apps/server/src/mcp/toolkits/environment/tools.ts @@ -1,4 +1,5 @@ import { + ProviderUsageLimitsMcpResult, BackgroundActivityProfile, BackgroundActivityProfileSelection, ExecutionEnvironmentDescriptor, @@ -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, @@ -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, +); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 588d11d9c715..b327e8a2bf73 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -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)({}), @@ -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. diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index de747802d349..46269326fbe7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -947,6 +947,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "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", diff --git a/apps/server/src/usage/UsageLimitsService.test.ts b/apps/server/src/usage/UsageLimitsService.test.ts new file mode 100644 index 000000000000..e283ca5a0f10 --- /dev/null +++ b/apps/server/src/usage/UsageLimitsService.test.ts @@ -0,0 +1,167 @@ +import { expect, it } from "@effect/vitest"; +import { + ProviderDriverKind, + ProviderInstanceId, + UsageLimitSourceId, + type ServerProvider, + type ServerProviderUsageLimits, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as UsageLimitSources from "./UsageLimitSources.ts"; +import * as UsageLimitsService from "./UsageLimitsService.ts"; + +const checkedAt = "1969-12-31T23:59:00.000Z"; +const window = { + id: "primary", + kind: "session", + label: "Session", + usedPercent: 25, + resetsAt: "1969-12-31T23:59:30.000Z", + windowDurationMins: 300, +} as const; +const provider = (id: string, usageLimits?: ServerProviderUsageLimits): ServerProvider => ({ + instanceId: ProviderInstanceId.make(id), + driver: ProviderDriverKind.make("codex"), + enabled: false, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + ...(usageLimits === undefined ? {} : { usageLimits }), + message: "secret-diagnostic", + runtimePaths: { homePath: "/private/secret", shadowHomePath: null }, +}); + +it.effect( + "reads all cached accounts, preserves freshness and quota, and omits sensitive metadata without probing", + () => + Effect.gen(function* () { + const limits = { + checkedAt, + windows: [window], + credentialFingerprint: "secret-fingerprint", + externalUsage: { label: "secret", url: "https://secret" }, + resetCredits: { availableCount: 1, nextCreditId: "secret-credit" }, + }; + const layerDependencies = Layer.mergeAll( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + provider("work", limits), + provider("personal", { + checkedAt, + windows: [ + { ...window, usedPercent: 100 }, + { id: "weekly", kind: "weekly", label: "Weekly", usedPercent: 0 }, + ], + }), + provider("missing"), + provider("unsupported", { + checkedAt, + windows: [], + unavailable: { reason: "unsupported", message: "secret-unavailable" }, + }), + provider("failed", { + checkedAt, + windows: [], + unavailable: { reason: "probeFailed", message: "secret-error" }, + }), + provider("empty", { checkedAt, windows: [] }), + ]), + refresh: () => Effect.die("must not refresh"), + refreshInstance: () => Effect.die("must not refresh"), + }), + Layer.mock(UsageLimitSources.UsageLimitSources)({ + current: Effect.succeed([ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy", + label: "secret-source-label", + checkedAt, + accounts: [ + { + id: "account", + driver: ProviderDriverKind.make("claudeCode"), + email: "secret@example.com", + plan: "secret-plan", + usageLimits: limits, + }, + ], + }, + { + id: UsageLimitSourceId.make("failed-hub"), + kind: "cliproxy", + label: "secret", + checkedAt, + accounts: [], + error: "secret-management-key", + }, + ]), + refresh: Effect.die("must not refresh"), + }), + ); + const result = yield* Effect.gen(function* () { + return yield* (yield* UsageLimitsService.UsageLimitsService).read; + }).pipe(Effect.provide(UsageLimitsService.layer.pipe(Layer.provide(layerDependencies)))); + expect(result.readAt).toBe("1970-01-01T00:00:00.000Z"); + expect(result.providers.map((entry) => entry.quota.status)).toEqual([ + "available", + "available", + "notReported", + "unavailable", + "error", + "available", + ]); + expect(result.providers[0]?.quota).toEqual({ + status: "available", + checkedAt, + ageSeconds: 60, + windows: [{ ...window, remainingPercent: 75 }], + }); + expect(result.providers[1]?.quota.windows[0]?.remainingPercent).toBe(0); + expect(result.providers[1]?.quota.windows[1]).toEqual({ + id: "weekly", + kind: "weekly", + label: "Weekly", + usedPercent: 0, + remainingPercent: 100, + }); + expect(result.providers[2]?.quota.checkedAt).toBeUndefined(); + expect(result.sources[0]?.accounts[0]?.quota.windows[0]?.remainingPercent).toBe(75); + expect(result.sources[1]).toEqual({ + sourceId: "failed-hub", + status: "error", + checkedAt, + ageSeconds: 60, + accounts: [], + }); + expect(JSON.stringify(result)).not.toContain("secret"); + }), +); + +it.effect("returns empty collections when no snapshots exist", () => + Effect.gen(function* () { + const service = yield* UsageLimitsService.UsageLimitsService; + expect(yield* service.read).toEqual({ + readAt: "1970-01-01T00:00:00.000Z", + providers: [], + sources: [], + }); + }).pipe( + Effect.provide( + UsageLimitsService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(UsageLimitSources.UsageLimitSources)({ current: Effect.succeed([]) }), + ), + ), + ), + ), + ), +); diff --git a/apps/server/src/usage/UsageLimitsService.ts b/apps/server/src/usage/UsageLimitsService.ts new file mode 100644 index 000000000000..653c7ca9cc0e --- /dev/null +++ b/apps/server/src/usage/UsageLimitsService.ts @@ -0,0 +1,76 @@ +import { + type ProviderUsageLimitsMcpResult, + type ServerProviderUsageLimits, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as UsageLimitSources from "./UsageLimitSources.ts"; + +export class UsageLimitsService extends Context.Service< + UsageLimitsService, + { readonly read: Effect.Effect } +>()("t3/usage/UsageLimitsService") {} + +const make = Effect.gen(function* () { + const providers = yield* ProviderRegistry.ProviderRegistry; + const sources = yield* UsageLimitSources.UsageLimitSources; + const read = Effect.gen(function* () { + const readAt = DateTime.formatIso(yield* DateTime.now); + const ageSeconds = (checkedAt: string) => + Math.max(0, (Date.parse(readAt) - Date.parse(checkedAt)) / 1000); + const quota = ( + limits: ServerProviderUsageLimits | undefined, + ): ProviderUsageLimitsMcpResult["providers"][number]["quota"] => ({ + status: + limits === undefined + ? "notReported" + : limits.unavailable?.reason === "probeFailed" + ? "error" + : limits.unavailable + ? "unavailable" + : "available", + ...(limits === undefined + ? {} + : { checkedAt: limits.checkedAt, ageSeconds: ageSeconds(limits.checkedAt) }), + // Project fields explicitly: provider diagnostics and credential identities never leave this read. + windows: (limits?.windows ?? []).map((window) => ({ + id: window.id, + kind: window.kind, + label: window.label, + usedPercent: window.usedPercent, + remainingPercent: 100 - window.usedPercent, + ...(window.resetsAt === undefined ? {} : { resetsAt: window.resetsAt }), + ...(window.windowDurationMins === undefined + ? {} + : { windowDurationMins: window.windowDurationMins }), + })), + }); + return { + readAt, + providers: (yield* providers.getProviders).map((provider) => ({ + instanceId: provider.instanceId, + driver: provider.driver, + enabled: provider.enabled, + quota: quota(provider.usageLimits), + })), + sources: (yield* sources.current).map((source) => ({ + sourceId: source.id, + status: source.error === undefined ? ("available" as const) : ("error" as const), + checkedAt: source.checkedAt, + ageSeconds: ageSeconds(source.checkedAt), + accounts: source.accounts.map((account) => ({ + accountId: account.id, + driver: account.driver, + quota: quota(account.usageLimits), + })), + })), + }; + }); + return UsageLimitsService.of({ read }); +}); + +export const layer = Layer.effect(UsageLimitsService, make); diff --git a/docs/user/usage.md b/docs/user/usage.md index 7474a30e8bc6..757758fa5b55 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -115,6 +115,22 @@ or endpoint configurations do not report subscription limits. API-key accounts may not report subscription limits. This also applies to Claude connections using a proxy through `ANTHROPIC_AUTH_TOKEN`. +## Read limits through MCP + +Agents and read-only MCP clients can call `t3_provider_usage_limits` with no arguments to +read this environment's cached provider and hub-account quota windows. Each window includes +used and remaining percentages and any reported reset timestamp. `checkedAt` is when the quota +was observed, and `ageSeconds` is its age at the result's `readAt`, when the MCP call read it. +Reading cached data does not refresh or re-observe the quota. +An elapsed reset timestamp does not prove that the provider has restored quota. + +`available` means the provider reported a snapshot, even if it contains no windows. +`notReported` means no quota snapshot exists, `unavailable` means the account cannot report +quota, and `error` means the latest published probe or hub read failed. Missing quota never +means zero or unlimited. A failed check may leave an older successful reading +available, so check its age. Account IDs are scoped to their provider instance or hub; +the same account may appear in both. Credentials and raw diagnostic messages are omitted. + ## Connect a CLIProxyAPI hub To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index c692a9b7d519..529faede3bdc 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -174,3 +174,44 @@ export const UsageLimitsReport = Schema.Struct({ notices: Schema.Array(Schema.String), }); export type UsageLimitsReport = typeof UsageLimitsReport.Type; + +const McpQuotaSnapshot = Schema.Struct({ + status: Schema.Literals(["available", "unavailable", "error", "notReported"]), + checkedAt: Schema.optional(IsoDateTime), + ageSeconds: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))), + windows: Schema.Array( + Schema.Struct({ + ...ServerProviderUsageWindow.fields, + remainingPercent: Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 100 })), + }), + ), +}); + +/** Sanitized cached quota read for MCP; timestamps describe observations, not live probes. */ +export const ProviderUsageLimitsMcpResult = Schema.Struct({ + readAt: IsoDateTime, + providers: Schema.Array( + Schema.Struct({ + instanceId: ProviderInstanceId, + driver: ProviderDriverKind, + enabled: Schema.Boolean, + quota: McpQuotaSnapshot, + }), + ), + sources: Schema.Array( + Schema.Struct({ + sourceId: UsageLimitSourceId, + status: Schema.Literals(["available", "error"]), + checkedAt: IsoDateTime, + ageSeconds: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)), + accounts: Schema.Array( + Schema.Struct({ + accountId: TrimmedNonEmptyString, + driver: ProviderDriverKind, + quota: McpQuotaSnapshot, + }), + ), + }), + ), +}); +export type ProviderUsageLimitsMcpResult = typeof ProviderUsageLimitsMcpResult.Type; diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 11abf77f704b..c4c466b1eed6 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -293,6 +293,10 @@ const T3_MCP_TOOLS: Readonly> = { ["Read", "Reading", "Read", "environment preferences"], "environment-read", ), + t3_provider_usage_limits: tool( + ["Read", "Reading", "Read", "provider usage limits"], + "environment-read", + ), t3_environment_preferences_update: tool( ["Update", "Updating", "Updated", "environment preferences"], "environment-update",