Skip to content
Merged
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
65 changes: 55 additions & 10 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ type ExecuteCodeOutcome =
readonly status: "paused";
readonly text: string;
readonly executionId: string | undefined;
readonly approvalUrl: string | undefined;
readonly interaction:
| {
readonly kind: "url" | "form";
Expand All @@ -419,6 +420,11 @@ type ExecuteCodeOutcome =
| undefined;
};

const buildResumeApprovalUrl = (baseUrl: string, executionId: string): string => {
const url = new URL(`/resume/${encodeURIComponent(executionId)}`, baseUrl);
return url.toString();
};

const executeCode = (input: {
baseUrl: string;
code: string;
Expand All @@ -433,10 +439,12 @@ const executeCode = (input: {
});

if (response.status === "paused") {
const executionId = extractExecutionId(response.structured);
return {
status: "paused" as const,
text: response.text,
executionId: extractExecutionId(response.structured),
executionId,
approvalUrl: executionId ? buildResumeApprovalUrl(daemonUrl, executionId) : undefined,
interaction: extractPausedInteraction(response.structured),
};
}
Expand All @@ -456,6 +464,10 @@ const printExecutionOutcome = (input: { baseUrl: string; outcome: ExecuteCodeOut
if (input.outcome.status === "paused") {
console.log(input.outcome.text);
if (input.outcome.executionId) {
if (input.outcome.approvalUrl) {
console.log("\nApprove in browser:");
console.log(` ${input.outcome.approvalUrl}`);
}
const commandPrefix = `${cliPrefix} resume --execution-id ${input.outcome.executionId} --base-url ${input.baseUrl}`;
if (input.outcome.interaction?.kind === "form") {
const requestedSchema = input.outcome.interaction.requestedSchema;
Expand All @@ -464,12 +476,12 @@ const printExecutionOutcome = (input: { baseUrl: string; outcome: ExecuteCodeOut
}
const template = buildResumeContentTemplate(requestedSchema);
const contentArg = shellQuoteArg(JSON.stringify(template));
console.log("\nResume commands:");
console.log("\nCLI fallback:");
console.log(` ${commandPrefix} --action accept --content ${contentArg}`);
console.log(` ${commandPrefix} --action decline`);
console.log(` ${commandPrefix} --action cancel`);
} else {
console.log("\nResume command:");
console.log("\nCLI fallback:");
console.log(` ${commandPrefix} --action accept`);
}
}
Expand Down Expand Up @@ -681,11 +693,21 @@ const withStdoutReroutedToStderr = async <A>(body: () => Promise<A>): Promise<A>
}
};

const runStdioMcpSession = () =>
const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) =>
Effect.gen(function* () {
const executor = yield* Effect.promise(() => withStdoutReroutedToStderr(() => getExecutor()));
yield* Effect.promise(() =>
runMcpStdioServer({ executor, codeExecutor: makeQuickJsExecutor() }),
runMcpStdioServer({
executor,
codeExecutor: makeQuickJsExecutor(),
elicitationMode:
input.elicitationMode === "browser"
? {
mode: "browser" as const,
approvalUrl: (executionId) => `/resume/${encodeURIComponent(executionId)}`,
}
: { mode: input.elicitationMode },
}),
);
});

Expand Down Expand Up @@ -1206,6 +1228,17 @@ const resumeCommand = Command.make(
payload: { action, content: contentObj },
});

if (result.status === "paused") {
console.log(result.text);
const nextExecutionId = extractExecutionId(result.structured);
if (nextExecutionId) {
console.log("");
console.log("Approval required:");
console.log(buildResumeApprovalUrl(daemonUrl, nextExecutionId));
}
process.exit(0);
}

if (result.isError) {
if (shouldPrintVerboseErrors(process.argv)) {
console.error(result.text);
Expand Down Expand Up @@ -1456,11 +1489,23 @@ const daemonCommand = Command.make("daemon").pipe(
Command.withDescription("Manage the local daemon"),
);

const mcpCommand = Command.make("mcp", { scope }, ({ scope }) =>
Effect.gen(function* () {
applyScope(scope);
yield* runStdioMcpSession();
}),
const mcpCommand = Command.make(
"mcp",
{
scope,
elicitationMode: Options.choice("elicitation-mode", ["browser", "model"] as const)
.pipe(Options.withDefault("browser"))
.pipe(
Options.withDescription(
"Choose the stdio approval flow: browser approval or a CLI resume tool exposed to the model.",
),
),
},
({ scope, elicitationMode }) =>
Effect.gen(function* () {
applyScope(scope);
yield* runStdioMcpSession({ elicitationMode });
}),
).pipe(Command.withDescription("Start an MCP server over stdio"));

// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions apps/cloud/src/api/execution-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ export const withExecutionUsageTracking = <E extends Cause.YieldableError>(
.pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))),
// resume doesn't count as usage
resume: (executionId, response) => engine.resume(executionId, response),
getPausedExecution: (executionId) => engine.getPausedExecution(executionId),
getDescription: engine.getDescription,
});
1 change: 1 addition & 0 deletions apps/cloud/src/api/protected.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const makeBaseEngine = (): ExecutionEngine =>
status: "completed",
result: { result: "ok", logs: [] },
}),
getPausedExecution: () => Effect.succeed(null),
getDescription: Effect.succeed("desc"),
}) as ExecutionEngine;

Expand Down
73 changes: 73 additions & 0 deletions apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,55 @@ const CreatedApiKeyResponse = Schema.Struct({

const ApiKeyParams = { apiKeyId: Schema.String };

const McpSessionExecutionParams = {
mcpSessionId: Schema.String,
executionId: Schema.String,
};

const ResumeMcpExecutionBody = Schema.Struct({
action: Schema.Literals(["accept", "decline", "cancel"]),
content: Schema.optional(Schema.Unknown),
});

const McpPausedExecutionResponse = Schema.Struct({
text: Schema.String,
structured: Schema.Unknown,
});

const McpResumeCompletedResponse = Schema.Struct({
status: Schema.Literal("completed"),
text: Schema.String,
structured: Schema.Unknown,
isError: Schema.Boolean,
});

const McpResumePausedResponse = Schema.Struct({
status: Schema.Literal("paused"),
text: Schema.String,
structured: Schema.Unknown,
});

const McpResumeExecutionResponse = Schema.Union([
McpResumeCompletedResponse,
McpResumePausedResponse,
]);

export class McpExecutionNotFoundError extends Schema.TaggedErrorClass<McpExecutionNotFoundError>()(
"McpExecutionNotFoundError",
{
executionId: Schema.String,
},
{ httpApiStatus: 404 },
) {}

export class McpSessionForbiddenError extends Schema.TaggedErrorClass<McpSessionForbiddenError>()(
"McpSessionForbiddenError",
{
mcpSessionId: Schema.String,
},
{ httpApiStatus: 403 },
) {}

export const AUTH_PATHS = {
login: "/api/auth/login",
logout: "/api/auth/logout",
Expand All @@ -116,6 +165,11 @@ export const AUTH_PATHS = {

const AuthErrors = [UserStoreError, WorkOSError] as const;
const ApiKeyErrors = [ApiKeyManagementError, NoOrganization, UserStoreError, WorkOSError] as const;
const McpApprovalErrors = [
NoOrganization,
McpExecutionNotFoundError,
McpSessionForbiddenError,
] as const;

/** Public auth endpoints — no authentication required */
export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")
Expand Down Expand Up @@ -187,4 +241,23 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
error: ApiKeyErrors,
}),
)
.add(
HttpApiEndpoint.get("getMcpPaused", "/mcp-sessions/:mcpSessionId/executions/:executionId", {
params: McpSessionExecutionParams,
success: McpPausedExecutionResponse,
error: McpApprovalErrors,
}),
)
.add(
HttpApiEndpoint.post(
"resumeMcpExecution",
"/mcp-sessions/:mcpSessionId/executions/:executionId/resume",
{
params: McpSessionExecutionParams,
payload: ResumeMcpExecutionBody,
success: McpResumeExecutionResponse,
error: McpApprovalErrors,
},
),
)
.middleware(SessionAuth) {}
109 changes: 108 additions & 1 deletion apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { HttpServerResponse } from "effect/unstable/http";
import { Duration, Effect, Predicate } from "effect";
import { setCookie, deleteCookie } from "@tanstack/react-start/server";

import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";
import {
AUTH_PATHS,
CloudAuthApi,
CloudAuthPublicApi,
McpExecutionNotFoundError,
McpSessionForbiddenError,
} from "./api";
import { NoOrganization, SessionContext } from "./middleware";
import { UserStoreService } from "./context";
import { authorizeOrganization } from "./authorize-organization";
Expand All @@ -18,6 +24,7 @@ import {
isOverFreeOrganizationLimit,
shouldApplyFreeOrganizationLimit,
} from "./organization-limits";
import type { McpSessionApprovalResult, McpSessionResumeApprovalResult } from "../mcp-session";

const COOKIE_OPTIONS = {
path: "/",
Expand Down Expand Up @@ -82,6 +89,45 @@ const requireSessionOrganization = Effect.gen(function* () {
return { session, org };
});

const requireSessionOrganizationId = Effect.gen(function* () {
const session = yield* SessionContext;
if (!session.organizationId) {
return yield* new NoOrganization();
}
return {
...session,
organizationId: session.organizationId,
};
});

const getMcpSessionStub = (mcpSessionId: string) =>
Effect.try({
try: () => {
const ns = env.MCP_SESSION;
return ns.get(ns.idFromString(mcpSessionId));
},
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => null));

const requireMcpSessionStub = (mcpSessionId: string, executionId: string) =>
Effect.gen(function* () {
const stub = yield* getMcpSessionStub(mcpSessionId);
if (!stub) {
return yield* new McpExecutionNotFoundError({ executionId });
}
return stub;
});

const failMcpApprovalResult = (
result: { readonly status: "not_found" | "forbidden" },
params: { readonly mcpSessionId: string; readonly executionId: string },
) => {
if (result.status === "forbidden") {
return Effect.fail(new McpSessionForbiddenError({ mcpSessionId: params.mcpSessionId }));
}
return Effect.fail(new McpExecutionNotFoundError({ executionId: params.executionId }));
};

const setResponseCookie = (
response: HttpServerResponse.HttpServerResponse,
name: string,
Expand Down Expand Up @@ -447,6 +493,67 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
});
}),
)
.handle("getMcpPaused", ({ params }) =>
Effect.gen(function* () {
const owner = yield* requireSessionOrganizationId;
const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId);
const result = yield* Effect.promise(
() =>
stub.getPausedExecutionForApproval(params.executionId, {
accountId: owner.accountId,
organizationId: owner.organizationId,
}) as Promise<McpSessionApprovalResult>,
);

if (result.status !== "ok") {
return yield* failMcpApprovalResult(result, params);
}

return {
text: result.text,
structured: result.structured,
};
}),
)
.handle("resumeMcpExecution", ({ params, payload }) =>
Effect.gen(function* () {
const owner = yield* requireSessionOrganizationId;
const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId);
const result = yield* Effect.promise(
() =>
stub.resumeExecutionForApproval(
params.executionId,
{
accountId: owner.accountId,
organizationId: owner.organizationId,
},
{
action: payload.action,
content: payload.content as Record<string, unknown> | undefined,
},
) as Promise<McpSessionResumeApprovalResult>,
);

if (result.status !== "ok") {
return yield* failMcpApprovalResult(result, params);
}

if (result.executionStatus === "paused") {
return {
status: "paused" as const,
text: result.text,
structured: result.structured,
};
}

return {
status: "completed" as const,
text: result.text,
structured: result.structured,
isError: result.isError ?? false,
};
}),
)
.handle("revokeApiKey", ({ params }) =>
Effect.gen(function* () {
const { session, org } = yield* requireSessionOrganization;
Expand Down
Loading
Loading