diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 14f12059ce5ce..5591517d0f6c2 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -398,6 +398,33 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer))( }).pipe(Effect.provide(layerEnvironmentAuth())), ); + it.effect("keeps MCP-subject browser cookies out of MCP listing and revocation", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const pairing = yield* serverAuth.createPairingLink({ subject: "mcp-client" }); + const browser = yield* serverAuth.createBrowserSession(pairing.credential, requestMetadata); + const browserSession = yield* sessions.verify(browser.sessionToken); + const mcp = yield* serverAuth.issueMcpClientSession({ + label: "Bot", + access: "read-only", + peerLink: false, + client: requestMetadata, + }); + + expect(browserSession.subject).toBe("mcp-client"); + expect((yield* serverAuth.listMcpClientSessions()).map((entry) => entry.sessionId)).toEqual( + [mcp.sessionId], + ); + expect(yield* serverAuth.revokeMcpClientSession(browserSession.sessionId)).toBe(false); + expect((yield* sessions.verify(browser.sessionToken)).sessionId).toBe( + browserSession.sessionId, + ); + expect(yield* serverAuth.revokeMcpClientSession(mcp.sessionId)).toBe(true); + expect(yield* serverAuth.listMcpClientSessions()).toEqual([]); + }).pipe(Effect.provide(layerEnvironmentAuth())), + ); + it.effect("narrows seeded desktop grants to the requested scopes", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 58c1f1bc0cadf..a0b39b027d7a9 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -548,11 +548,13 @@ export class EnvironmentAuth extends Context.Service< readonly authenticateMcpClient: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect; + /** Issues the bearer credential an OAuth approval grants; 30 days unless `ttl` says otherwise. */ readonly issueMcpClientSession: (input: { readonly label: string; readonly access: AuthMcpClientAccess; readonly peerLink: boolean; readonly client: AuthClientMetadata; + readonly ttl?: Duration.Duration; }) => Effect.Effect< { readonly sessionId: AuthSessionId; @@ -561,6 +563,14 @@ export class EnvironmentAuth extends Context.Service< }, ServerAuthInternalError >; + readonly listMcpClientSessions: () => Effect.Effect< + ReadonlyArray, + ServerAuthInternalError + >; + /** Revokes only an MCP client credential; false for any other session id. */ + readonly revokeMcpClientSession: ( + sessionId: AuthSessionId, + ) => Effect.Effect; /** * Spends a code covering all granted scopes and returns the level it approves. A level-bound * code must name `access`. Rejects desktop grants and leaves refused codes unspent. @@ -1344,7 +1354,7 @@ export const make = Effect.gen(function* () { subject: MCP_CLIENT_SUBJECT, method: "bearer-access-token", scopes: mcpClientScopes(input.access), - ttl: MCP_CLIENT_SESSION_TTL, + ttl: input.ttl ?? MCP_CLIENT_SESSION_TTL, ...(input.access === "read-only" ? {} : { runtimeModeCeiling: input.access }), peerLink: input.peerLink, client: { ...input.client, label: input.label, deviceType: "bot" }, @@ -1359,6 +1369,25 @@ export const make = Effect.gen(function* () { Effect.withSpan("EnvironmentAuth.issueMcpClientSession"), ); + const listMcpClientSessions: EnvironmentAuth["Service"]["listMcpClientSessions"] = () => + listSessions().pipe( + Effect.map((active) => + active.filter( + (session) => + session.subject === MCP_CLIENT_SUBJECT && session.method === "bearer-access-token", + ), + ), + Effect.withSpan("EnvironmentAuth.listMcpClientSessions"), + ); + + const revokeMcpClientSession: EnvironmentAuth["Service"]["revokeMcpClientSession"] = Effect.fn( + "EnvironmentAuth.revokeMcpClientSession", + )(function* (sessionId) { + const mcpClients = yield* listMcpClientSessions(); + if (!mcpClients.some((session) => session.sessionId === sessionId)) return false; + return yield* revokeSession(sessionId); + }); + const consumeMcpApprovalCode: EnvironmentAuth["Service"]["consumeMcpApprovalCode"] = ( code, access, @@ -1410,6 +1439,8 @@ export const make = Effect.gen(function* () { issueStartupPairingUrl, authenticateMcpClient, issueMcpClientSession, + listMcpClientSessions, + revokeMcpClientSession, consumeMcpApprovalCode, }); }); diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index b75fba88bddc3..3607f826b79c1 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -1,4 +1,4 @@ -import { AuthSessionId, AuthStandardClientScopes } from "@t3tools/contracts"; +import { AuthMcpClientAccess, AuthSessionId, AuthStandardClientScopes } from "@t3tools/contracts"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -10,8 +10,10 @@ import { Argument, Command, Flag, GlobalFlag } from "effect/cli"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import { + formatIssuedMcpClient, formatIssuedPairingCredential, formatIssuedSession, + formatMcpClientList, formatPairingCredentialList, formatSessionList, } from "../cliAuthFormat.ts"; @@ -248,7 +250,99 @@ const sessionCommand = Command.make("session").pipe( Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]), ); +const mcpIssueCommand = Command.make("issue", { + ...authLocationFlags, + access: Flag.Literals("access", AuthMcpClientAccess.literals).pipe( + Flag.withDescription( + "What the client may do, as on the MCP sign-in page: read-only, approval-required (Supervised), auto-accept-edits, auto, or full-access.", + ), + ), + label: Flag.String("label").pipe( + Flag.withDescription("Name of the client, shown in `t3 auth mcp list`."), + ), + ttl: ttlFlag.pipe(Flag.withDescription("TTL, for example `1h` or `90d`. Defaults to 30 days.")), + tokenOnly: tokenOnlyFlag, + json: jsonFlag, +}).pipe( + Command.withDescription( + "Issue a bearer token for an MCP client that cannot finish the OAuth sign-in.", + ), + Command.withHandler((flags) => + runWithEnvironmentAuth( + flags, + (environmentAuth) => + Effect.gen(function* () { + const issued = yield* environmentAuth.issueMcpClientSession({ + label: flags.label, + access: flags.access, + peerLink: false, + client: { deviceType: "bot" }, + ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), + }); + yield* Console.log( + formatIssuedMcpClient( + { ...issued, label: flags.label, access: flags.access }, + { json: flags.json, tokenOnly: flags.tokenOnly }, + ), + ); + }), + { + quietLogs: flags.json || flags.tokenOnly, + }, + ), + ), +); + +const mcpListCommand = Command.make("list", { + ...authLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription( + "List active MCP client credentials, including OAuth sign-ins, without revealing tokens.", + ), + Command.withHandler((flags) => + runWithEnvironmentAuth( + flags, + (environmentAuth) => + Effect.gen(function* () { + const clients = yield* environmentAuth.listMcpClientSessions(); + yield* Console.log(formatMcpClientList(clients, { json: flags.json })); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const mcpRevokeCommand = Command.make("revoke", { + ...authLocationFlags, + sessionId: Argument.String("session-id").pipe( + Argument.withDescription("MCP client credential id to revoke, from `t3 auth mcp list`."), + Argument.withSchema(AuthSessionId), + ), +}).pipe( + Command.withDescription("Revoke an MCP client credential. Its next /mcp request gets HTTP 401."), + Command.withHandler((flags) => + runWithEnvironmentAuth(flags, (environmentAuth) => + Effect.gen(function* () { + const revoked = yield* environmentAuth.revokeMcpClientSession(flags.sessionId); + yield* Console.log( + revoked + ? `Revoked MCP client credential ${flags.sessionId}.\n` + : `No active MCP client credential found for ${flags.sessionId}.\n`, + ); + }), + ), + ), +); + +const mcpCommand = Command.make("mcp").pipe( + Command.withDescription("Manage bearer credentials for outside MCP clients."), + Command.withSubcommands([mcpIssueCommand, mcpListCommand, mcpRevokeCommand]), +); + export const authCommand = Command.make("auth").pipe( Command.withDescription("Manage the local auth control plane for headless deployments."), - Command.withSubcommands([pairingCommand, sessionCommand]), + Command.withSubcommands([pairingCommand, sessionCommand, mcpCommand]), ); diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index e626461914a06..8841361050f7f 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -4,9 +4,15 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import { NodeHttpServer } from "@effect/platform-node"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { AuthStandardClientScopes, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; +import { + AuthStandardClientScopes, + EnvironmentId, + ORCHESTRATION_PROTOCOL_VERSION, + OrchestratorMcpFailure, +} from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, expect, it } from "@effect/vitest"; @@ -15,20 +21,32 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { FetchHttpClient } from "effect/http"; +import { Tool, Toolkit } from "effect/ai"; +import { FetchHttpClient, HttpBody, HttpClient, HttpRouter } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as TestClock from "effect/testing/TestClock"; import * as TestConsole from "effect/testing/TestConsole"; import { Command, CliError } from "effect/cli"; +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as McpOAuth from "../auth/McpOAuth.ts"; import { cli } from "../binCli.ts"; import { SERVICE_LAUNCHER_CONTEXT_ENV, SERVICE_LAUNCHER_PROTOCOL, } from "../cloud/serviceProtocol.ts"; import * as ServiceLauncherClient from "../cloud/serviceLauncherClient.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; +import * as McpToolAccess from "../mcp/McpToolAccess.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { makePersistedServerRuntimeState, persistServerRuntimeState, @@ -606,6 +624,8 @@ describe("scripted auth output", () => { ["auth", "session", "issue", "--token-only"], ["auth", "session", "list", "--json"], ["auth", "pairing", "list", "--json"], + ["auth", "mcp", "issue", "--access", "read-only", "--label", "Bot", "--token-only"], + ["auth", "mcp", "list", "--json"], ].map((command) => ({ command, label: command.join(" ") })), )("prints only the result for $label after removing an abandoned upload", ({ command }) => Effect.gen(function* () { @@ -638,3 +658,208 @@ describe("scripted auth output", () => { }), ); }); + +describe("auth mcp", () => { + const AccessProbe = Toolkit.make( + Tool.make("access", { + description: "Reports the caller's access level.", + success: Schema.Struct({ access: Schema.String }), + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: [McpInvocationContext.McpInvocationContext], + }), + Tool.make("change", { + description: "Changes the environment.", + success: Schema.Struct({ changed: Schema.Boolean }), + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + ], + }), + ); + + // Serves /mcp over the same state the CLI wrote, as a running server would. + const serveMcp = (baseDir: string) => + HttpRouter.serve( + McpHttpServer.layerMcpTransport( + McpHttpServer.toolkitRegistration( + AccessProbe, + McpToolAccess.toLayer(AccessProbe, { + access: McpToolAccess.reads(() => + McpInvocationContext.McpInvocationContext.pipe( + Effect.map((scope) => ({ access: scope.client?.access ?? "none" })), + ), + ), + change: McpToolAccess.writes(() => Effect.succeed({ changed: true })), + }), + ), + ).pipe( + Layer.provide(McpOAuth.layerMcpClientAuthenticator), + Layer.provide(ServerSettings.layerTest()), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: () => Effect.undefined, + }), + ), + Layer.provide(Layer.mock(ThreadManagementService.ThreadManagementService)({})), + Layer.provide( + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getEnvironmentId: Effect.succeed(EnvironmentId.make("auth-mcp-test")), + }), + ), + ), + { disableListenLog: true, disableLogger: true }, + ).pipe( + Layer.provideMerge(EnvironmentAuth.layerRuntime), + Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + Layer.build, + ); + + const callTool = (token: string, name: "access" | "change") => + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const headers = { + accept: "application/json, text/event-stream", + authorization: `Bearer ${token}`, + }; + const initialize = yield* httpClient.post("/mcp", { + headers, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"Grok Bot","version":"1.0.0"}}}`, + "application/json", + ), + }); + if (initialize.status !== 200) return { status: initialize.status } as const; + const called = yield* httpClient.post("/mcp", { + headers: { + ...headers, + "mcp-session-id": initialize.headers["mcp-session-id"]!, + "mcp-protocol-version": "2025-06-18", + }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"${name}","arguments":{}}}`, + "application/json", + ), + }); + const body = (yield* called.json) as { + readonly result: { readonly isError?: boolean; readonly structuredContent?: unknown }; + }; + return { status: called.status, result: body.result } as const; + }); + + it.effect( + "issues MCP client credentials that /mcp accepts at their access level until revoked or expired", + () => + Effect.scoped( + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-mcp-")); + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(baseDir, { recursive: true, force: true })), + ); + const issue = (access: string, ttl: string) => + captureStdout( + runCli([ + "auth", + "mcp", + "issue", + "--base-dir", + baseDir, + "--access", + access, + "--label", + `Grok Bot ${access}`, + "--ttl", + ttl, + "--json", + ]), + ).pipe( + Effect.map( + (output) => + JSON.parse(output) as { + readonly sessionId: string; + readonly token: string; + readonly access: string; + readonly label: string; + readonly expiresAt: string; + }, + ), + ); + const listMcp = captureStdout( + runCli(["auth", "mcp", "list", "--base-dir", baseDir, "--json"]), + ).pipe( + Effect.map( + (output) => + JSON.parse(output) as ReadonlyArray<{ + readonly sessionId: string; + readonly label: string; + }>, + ), + ); + + const readOnly = yield* issue("read-only", "2h"); + const fullAccess = yield* issue("full-access", "1h"); + // A browser session is not an MCP credential, so `auth mcp` neither lists nor revokes it. + const browser = JSON.parse( + yield* captureStdout( + runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"]), + ), + ) as { readonly sessionId: string }; + + assert.deepInclude(readOnly, { + access: "read-only", + label: "Grok Bot read-only", + expiresAt: "1970-01-01T02:00:00.000Z", + }); + assert.equal(fullAccess.expiresAt, "1970-01-01T01:00:00.000Z"); + assert.sameDeepMembers( + (yield* listMcp).map(({ sessionId, label }) => ({ sessionId, label })), + [ + { sessionId: readOnly.sessionId, label: "Grok Bot read-only" }, + { sessionId: fullAccess.sessionId, label: "Grok Bot full-access" }, + ], + ); + + yield* serveMcp(baseDir); + assert.deepInclude((yield* callTool(readOnly.token, "access")).result, { + structuredContent: { access: "read-only" }, + }); + const denied = (yield* callTool(readOnly.token, "change")).result; + assert.isTrue(denied?.isError); + assert.include(JSON.stringify(denied), "capability_denied"); + assert.deepInclude((yield* callTool(fullAccess.token, "access")).result, { + structuredContent: { access: "full-access" }, + }); + assert.deepInclude((yield* callTool(fullAccess.token, "change")).result, { + structuredContent: { changed: true }, + }); + + assert.equal( + yield* captureStdout( + runCli(["auth", "mcp", "revoke", browser.sessionId, "--base-dir", baseDir]), + ), + `No active MCP client credential found for ${browser.sessionId}.\n`, + ); + assert.equal( + yield* captureStdout( + runCli(["auth", "mcp", "revoke", readOnly.sessionId, "--base-dir", baseDir]), + ), + `Revoked MCP client credential ${readOnly.sessionId}.\n`, + ); + assert.deepEqual(yield* callTool(readOnly.token, "access"), { status: 401 }); + assert.deepEqual( + (yield* listMcp).map(({ sessionId }) => sessionId), + [fullAccess.sessionId], + ); + + yield* TestClock.adjust("1 hour"); + assert.deepEqual(yield* callTool(fullAccess.token, "access"), { status: 401 }); + }), + ).pipe( + Effect.provide( + Layer.mergeAll(NodeHttpServer.layerTest, layerCliRuntime, TestConsole.layer), + ), + ), + ); +}); diff --git a/apps/server/src/cliAuthFormat.ts b/apps/server/src/cliAuthFormat.ts index da87d1cc9280a..dd1597ed36db6 100644 --- a/apps/server/src/cliAuthFormat.ts +++ b/apps/server/src/cliAuthFormat.ts @@ -1,4 +1,10 @@ -import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; +import type { + AuthClientMetadata, + AuthClientSession, + AuthMcpClientAccess, + AuthPairingLink, + AuthSessionId, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import type { IssuedBearerSession, IssuedPairingLink } from "./auth/EnvironmentAuth.ts"; @@ -190,3 +196,89 @@ export function formatSessionList( .join(`${newline}${newline}`) + newline ); } + +export function formatIssuedMcpClient( + issued: { + readonly sessionId: AuthSessionId; + readonly token: string; + readonly expiresAt: DateTime.DateTime; + readonly label: string; + readonly access: AuthMcpClientAccess; + }, + options?: { + readonly json?: boolean; + readonly tokenOnly?: boolean; + }, +): string { + if (options?.tokenOnly) { + return `${issued.token}${newline}`; + } + + if (options?.json) { + return `${JSON.stringify( + { + sessionId: issued.sessionId, + token: issued.token, + label: issued.label, + access: issued.access, + expiresAt: toIsoString(issued.expiresAt), + }, + null, + 2, + )}${newline}`; + } + + return ( + [ + `Issued MCP client credential ${issued.sessionId} (${issued.label}).`, + `Access: ${issued.access}`, + `Token: ${issued.token}`, + `Expires at: ${toIsoString(issued.expiresAt)}`, + "Send it to the environment's /mcp URL as `Authorization: Bearer `.", + "Use HTTPS, localhost, or an authenticated encrypted tunnel to protect the token.", + ].join(newline) + newline + ); +} + +export function formatMcpClientList( + sessions: ReadonlyArray, + options?: { + readonly json?: boolean; + }, +): string { + if (options?.json) { + return `${JSON.stringify( + sessions.map((session) => ({ + sessionId: session.sessionId, + label: session.client.label ?? null, + scopes: session.scopes, + connected: session.connected, + issuedAt: toIsoString(session.issuedAt), + expiresAt: toIsoString(session.expiresAt), + lastConnectedAt: session.lastConnectedAt ? toIsoString(session.lastConnectedAt) : null, + })), + null, + 2, + )}${newline}`; + } + + if (sessions.length === 0) { + return `No active MCP client credentials.${newline}`; + } + + return ( + sessions + .map((session) => + [ + `${session.sessionId} (${session.client.label ?? "MCP client"})${session.connected ? " connected" : ""}`, + ` scopes: ${session.scopes.join(" ")}`, + ` issued: ${toIsoString(session.issuedAt)}`, + ` last connected: ${ + session.lastConnectedAt ? toIsoString(session.lastConnectedAt) : "never" + }`, + ` expires: ${toIsoString(session.expiresAt)}`, + ].join(newline), + ) + .join(`${newline}${newline}`) + newline + ); +} diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index 84d00cc98b16c..7daabe5deb378 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -133,7 +133,23 @@ Add the environment as a custom MCP app in ChatGPT's apps settings: Any agent that supports remote MCP servers over HTTP with OAuth can connect. Give it the MCP URL and choose OAuth, and the agent finds the sign-in settings itself. -Agents without OAuth support cannot connect. + +An agent that cannot finish the OAuth sign-in, or only accepts a fixed bearer +token, can use a token issued on the host instead: + +```sh +t3 auth mcp issue --access full-access --label "Grok Bot" --token-only +``` + +Give the agent the MCP URL and send the token as `Authorization: Bearer `. +Use HTTPS, localhost, or an authenticated encrypted tunnel to protect the token. +`--access` takes the same levels as the sign-in page: `read-only`, +`approval-required` (Supervised), `auto-accept-edits`, `auto` or `full-access`. +The token lasts 30 days unless you pass `--ttl`, such as `--ttl 90d`. Anyone who +holds it gets that access within the environment's managed policy limits, so +keep it in the agent's secret settings. `t3 auth mcp list` shows active MCP client +credentials, including OAuth sign-ins, linked environments and `t3 thread`. +`t3 auth mcp revoke ` cuts one off at its next request. ## Scripts on the host