diff --git a/AGENTS.md b/AGENTS.md index 68e692b9d..91b9e0b36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ Key rules: * `src/core/shell_command/safe_git.zig` runs git for the `fx pr` and `fx issue` snapshot and for the agent's direct read-only `git status`, `git diff`, and `git log` when the command does not use the user's own shell environment. It turns off every program a repository's own config can name and refuses to run when git would use a different repository than the one fx read. In `auto` mode a git command it refuses is returned to the agent without running. Commands that run in a shell use git with the repository's full config: git in the shell tool's default `user` environment, and the routine commands `auto` mode runs without review, such as `git status` with other flags, `git fetch`, or `npm test`. -* `src/mcp/` is the MCP-v2 engine. `FX_MCP_ENGINE=v2` selects it in place of the default v1 client in `src/core/mcp/`. Its files import only `std` and each other, and the host passes in `std.Io` and every other service through `src/mcp/host.zig`. fx connects to it through `src/core/mcp_host/`. +* `src/mcp/` is the MCP engine. Its files import only `std` and each other, and the host passes in `std.Io` and every other service through `src/mcp/host.zig`. fx connects to it through `src/core/mcp_host/`. `src/core/mcp/` holds the MCP types, config parsing, schema checks, search, and tool naming that the host, the tools, ACP, and the UI share. * `src/tools/` owns built-in tool implementations. Generic tool contracts and dispatch live in `src/core/tooling/`. Default tool specs are centralized in `src/core/tooling/tool_specs.zig` or `src/builtins/tools.zig`, not in individual tool files. diff --git a/README.md b/README.md index f5a4a7750..f4ce0ec8c 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ fx/ │ ├── tools/ built-in tools: files, shell, web, skills, subagents │ ├── ui/ terminal rendering and input │ ├── gateway/ model provider transport -│ ├── mcp/ MCP v2 engine, behind FX_MCP_ENGINE=v2 +│ ├── mcp/ MCP engine │ └── acp/ Agent Client Protocol server ├── sdk/ libfx, the JavaScript library ├── tests/ end-to-end tests and model evals diff --git a/sdk/README.md b/sdk/README.md index 0fe4a2950..a8404f6ce 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -436,7 +436,7 @@ const agent = createFxAgent({ `wait.start` and `wait.end` describe actual input requests separately from permission evaluation. Their `kind` is `approval`, `question`, `mcp_form`, or `mcp_url`; request IDs pair outcomes, including denial, cancellation, and failure. Native delegation events carry the child session and durable work ID. Child events retain that work ID, their own session/turn/execution identity, and the root and parent session IDs, so the host can join them even if delivery interleaves. -MCP events include the configured server, original remote tool name, and owning fx call ID. `mcp.request.data.requestIdKind` distinguishes a native JSON-RPC ID, an MCP-v2 engine handle, and an explicitly propagated host correlation token. Hooks don't change remote MCP requests by default: the host adapter emits `mcp.start` and `mcp.end` without adding metadata or emitting a request ID. Opt in with `createMcpAdapter(client, { propagateCorrelation: true })` to send an opaque UUID as `_meta["libfx/correlationId"]` and record it in `mcp.request`. It contains no internal execution/call IDs and doesn't request progress notifications. Give the adapter a `serverName` for a stable local identity; otherwise it uses the client's reported server name when available. Native resource/prompt operations also report their server and action. +MCP events include the configured server, original remote tool name, and owning fx call ID. `mcp.request.data.requestIdKind` distinguishes a native JSON-RPC ID, an MCP engine handle, and an explicitly propagated host correlation token. Hooks don't change remote MCP requests by default: the host adapter emits `mcp.start` and `mcp.end` without adding metadata or emitting a request ID. Opt in with `createMcpAdapter(client, { propagateCorrelation: true })` to send an opaque UUID as `_meta["libfx/correlationId"]` and record it in `mcp.request`. It contains no internal execution/call IDs and doesn't request progress notifications. Give the adapter a `serverName` for a stable local identity; otherwise it uses the client's reported server name when available. Native resource/prompt operations also report their server and action. `mcp.end.status` uses `success`, `failure`, `cancelled`, `input_required`, `unavailable`, or `unknown`. Native tool/protocol failures map to `failure`; `unavailable` means no operation result was available, and `unknown` means observation couldn't classify a returned host result. diff --git a/sdk/semantic-observer-hooks.md b/sdk/semantic-observer-hooks.md index e23892786..65675800d 100644 --- a/sdk/semantic-observer-hooks.md +++ b/sdk/semantic-observer-hooks.md @@ -54,7 +54,7 @@ The main model, compaction, review, vision, and title paths report distinct purposes without synthesizing user turns for auxiliary calls. Review retries have separate model-call IDs. Title tasks borrow an observer only while their owner guarantees a join before releasing it. MCP instrumentation stays in fx's -host boundary; the isolated MCP-v2 engine has no tracing imports. +host boundary; the isolated MCP engine has no tracing imports. Native child runtimes allocate a fresh execution observer and carry the existing durable message/work identity. They don't retain a pointer to a parent's diff --git a/src/acp/prompt.zig b/src/acp/prompt.zig index a7e0906f2..b46f905c7 100644 --- a/src/acp/prompt.zig +++ b/src/acp/prompt.zig @@ -3473,7 +3473,7 @@ fn requestAcpElicitation( .{}, ); var transition_state: mcp_elicitation.RequestState = .pending; - // MCP-v2 ties an answer to the call that asked, and drops it once that + // The MCP engine ties an answer to the call that asked, and drops it once that // call has ended, so its server counts as live here. const host_session = if (responder.acp.state.active_session) |*active| active.mcp_host != null else false; const transition = mcp_elicitation.decideTransition( @@ -3492,7 +3492,7 @@ fn requestAcpElicitation( if (input_request.mode == .url and action == .accept) { if (legacy_source_id != null) { const sink = server.legacyUrlCompletionSink(state); - // MCP-v2's host has no generations to check: the sink takes the + // The MCP host has no generations to check: the sink takes the // consent itself. if (!host_session) return error.McpInputRequired; const accept_result = sink.settleAccept(sink.accept(sink.context, origin, url_id.?)) orelse return error.McpInputRequired; diff --git a/src/acp/server.zig b/src/acp/server.zig index 2063954b7..afab03385 100644 --- a/src/acp/server.zig +++ b/src/acp/server.zig @@ -2818,7 +2818,7 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message .mcp_servers = state.cfg.allow_acp_mcp, .steering = !host_target.is_wasm and !state.cfg.minimal_kernel, .system_prompt = !host_target.is_wasm and !state.cfg.minimal_kernel, - // fx takes only what ACP v1 defines and MCP-v2 speaks: stdio and + // fx takes only what ACP v1 defines and fx's MCP engine speaks: stdio and // HTTP. .mcp_over_acp = false, .mcp_sse = false, diff --git a/src/acp/types.zig b/src/acp/types.zig index b2a45eb54..e3f255a6e 100644 --- a/src/acp/types.zig +++ b/src/acp/types.zig @@ -305,7 +305,7 @@ pub const AgentCapabilities = struct { system_prompt: bool = false, /// Serves `type: "acp"` MCP servers over this connection. mcp_over_acp: bool = false, - /// Speaks MCP's HTTP+SSE transport (2024-11-05), which MCP-v2 doesn't. + /// Speaks MCP's HTTP+SSE transport (2024-11-05), which fx's MCP engine doesn't. mcp_sse: bool = true, }; diff --git a/src/builtins/commands.zig b/src/builtins/commands.zig index ec6cf5469..2f112da42 100644 --- a/src/builtins/commands.zig +++ b/src/builtins/commands.zig @@ -146,23 +146,8 @@ pub const top_level_specs = [_]TopLevelSpec{ .kind = .mcp, .token = "mcp", .usage = "mcp ...", + // `fx mcp -h` is the verbs' own help (`mcp_host/verbs.zig` writeUsage). .summary = "Manage MCP servers without opening the interactive shell", - .details = &.{ - "Commands:", - " fx mcp add slack Configure Slack and open authorization", - " fx mcp add NAME COMMAND [ARGS...]", - " fx mcp add --transport http NAME URL", - " fx " ++ command_specs.mcp_auth_usage, - " fx mcp list [--connect]", - " fx mcp logout NAME", - " fx mcp path", - " fx mcp remove NAME", - " fx mcp trust approve|reject NAME", - " fx mcp trust approve-all|reset", - "", - "By default, list reads configuration without opening MCP transports.", - "Use --connect to connect and discover servers before rendering health.", - }, }, .{ .kind = .models, diff --git a/src/core/app/app_commands.zig b/src/core/app/app_commands.zig index 73401b64e..e4f5d5f76 100644 --- a/src/core/app/app_commands.zig +++ b/src/core/app/app_commands.zig @@ -318,7 +318,7 @@ pub fn Handlers(comptime App: type) type { }; } - /// What MCP-v2 and `/mcp` have to say, and the reload a `/mcp` change asked for. + /// What the MCP host and `/mcp` have to say, and the reload a `/mcp` change asked for. fn closeOtherMenusForMcp(app: *App) void { closeModelMenuIfPresent(app); closeHelpMenuIfPresent(app); @@ -337,7 +337,7 @@ pub fn Handlers(comptime App: type) type { pub fn collectMcpHostFacts(app: *App) !void { if (comptime !@hasDecl(App, "takeMcpHostNotices")) return; app.applyMcpHostReload() catch |err| { - debug_trace.logf("mcp", "MCP-v2 reload failed err={s}", .{@errorName(err)}); + debug_trace.logf("mcp", "MCP reload failed err={s}", .{@errorName(err)}); try app.writeDomainNotice(.{ .topic = "mcp", .tone = .warning, .body = "The MCP config changed, but fx couldn't load it again. Your current servers are still active." }, true); }; const notices = app.takeMcpHostNotices(); @@ -1961,7 +1961,7 @@ fn writeSessionTitleSummary(writer: *std.Io.Writer, app: anytype, alloc: std.mem try writer.writeByte('\n'); } -/// MCP-v2's servers, one line each; false when there is no host to ask. +/// The MCP servers, one line each; false when there is no host to ask. fn writeMcpHostServers(writer: *std.Io.Writer, app: anytype, alloc: std.mem.Allocator) !bool { if (comptime !@hasDecl(@TypeOf(app.*), "mcpHostStatuses")) return false; var arena = std.heap.ArenaAllocator.init(alloc); @@ -1990,7 +1990,7 @@ fn writeMcpHostServers(writer: *std.Io.Writer, app: anytype, alloc: std.mem.Allo return true; } -/// MCP-v2's servers that need someone to act: down, retrying, missing a +/// The MCP servers that need someone to act: down, retrying, missing a /// variable, or waiting for a login. fn writeMcpHostProblems(writer: *std.Io.Writer, app: anytype, alloc: std.mem.Allocator) !usize { if (comptime !@hasDecl(@TypeOf(app.*), "mcpHostStatuses")) return 0; diff --git a/src/core/app/app_input_runtime.zig b/src/core/app/app_input_runtime.zig index de19b2a02..43e064cb0 100644 --- a/src/core/app/app_input_runtime.zig +++ b/src/core/app/app_input_runtime.zig @@ -1706,7 +1706,7 @@ pub fn Runtime(comptime App: type) type { return app.input_runtime.settings_menu.active; } - /// The `/mcp` menu on MCP-v2. + /// The `/mcp` menu. fn mcpHostMenuActive(app: *App) bool { if (comptime !@hasDecl(App, "mcpHostMenuKey")) return false; return app.mcp.hostMenuActive(); diff --git a/src/core/app/app_mcp_runtime.zig b/src/core/app/app_mcp_runtime.zig index 625e20e55..bbb6b0c1c 100644 --- a/src/core/app/app_mcp_runtime.zig +++ b/src/core/app/app_mcp_runtime.zig @@ -38,8 +38,7 @@ fn typedLoginServer(arena: Allocator, args: []const []const u8) Allocator.Error! /// Owns the native profile MCP runtime. All transport work and retirement happen /// after releasing this state lock; the lock protects only pointer publication. pub const State = struct { - /// MCP-v2's host when FX_MCP_ENGINE=v2 selects it; `runtime` then stays - /// null. Replaced on the UI thread when `/mcp` changes the + /// The MCP host. Replaced on the UI thread when `/mcp` changes the /// config; other threads use it through `leaseHost`. host: ?*mcp_host.Host = null, /// Guards `host` against other threads, and its leases. diff --git a/src/core/cli/cli_surface.zig b/src/core/cli/cli_surface.zig index da8b5d45c..774a71e36 100644 --- a/src/core/cli/cli_surface.zig +++ b/src/core/cli/cli_surface.zig @@ -1108,7 +1108,7 @@ fn runNonInteractiveWithDeps( return .handled_success; } - // MCP-v2's verbs print their own help, and pass `-h` after `--` to the server. + // The MCP verbs print their own help, and pass `-h` after `--` to the server. if (topLevelHelpRequest(cfg.command_catalog, effective_args)) |kind| if (kind != .mcp) { const text = try command_specs.renderTopLevelCommandHelp(alloc, cfg.command_catalog, kind); defer alloc.free(text); @@ -2411,7 +2411,7 @@ fn runTopLevelMcp( return runMcpVerbs(alloc, rest, cfg, deps); } -/// `fx mcp` on MCP-v2: the verbs `/mcp` shares. +/// `fx mcp`: the verbs `/mcp` shares. fn runMcpVerbs(alloc: Allocator, rest: []const [:0]const u8, cfg: Config, deps: RunDeps) !RunResult { const home = deps.getenv(deps.env_ctx, "HOME") orelse { try writeStderr(deps, "fx mcp: HOME isn't set\n"); diff --git a/src/core/hosts/native_keychain.zig b/src/core/hosts/native_keychain.zig index 3c9acce08..974840e5f 100644 --- a/src/core/hosts/native_keychain.zig +++ b/src/core/hosts/native_keychain.zig @@ -23,7 +23,7 @@ const FindGenericPasswordFn = *const fn ( pub const service_name = "FX_AI_GATEWAY_API_KEY"; const mcp_credentials_service_name = "FX_MCP_OAUTH_CREDENTIALS_V1"; -/// The sign-in credentials MCP-v2 hands fx to keep, a different format. +/// The sign-in credentials the MCP engine hands fx to keep, a different format. pub const mcp_host_credentials_service_name = "FX_MCP_OAUTH_CREDENTIALS_V2"; pub const oauth_session_service_name = "FX_OAUTH_SESSION_V1"; diff --git a/src/core/mcp/health.zig b/src/core/mcp/health.zig index 47be529bf..53810c18c 100644 --- a/src/core/mcp/health.zig +++ b/src/core/mcp/health.zig @@ -1,9 +1,10 @@ +//! What fx knows of a configured MCP server: its config, as `fx status` +//! and `fx doctor` inspect it, and the status the model catalog gives it. + const std = @import("std"); const mcp_contract = @import("mcp_contract.zig"); -const text_utils = @import("../shared/text_utils.zig"); const Allocator = std.mem.Allocator; -const max_pending_summary_names: usize = 4; pub const ConnectionState = enum { disconnected, @@ -13,19 +14,6 @@ pub const ConnectionState = enum { failed, }; -pub fn observedConnection( - published: ConnectionState, - transport_running: ?bool, -) ConnectionState { - if (published == .ready and transport_running == false) return .failed; - return published; -} - -pub fn retryDelay(retry_at_ms: ?u64, captured_at_ms: u64) ?u64 { - const retry_at = retry_at_ms orelse return null; - return retry_at -| captured_at_ms; -} - pub const AuthenticationState = enum { none, configured, @@ -33,11 +21,8 @@ pub const AuthenticationState = enum { required, }; -/// The single product-level status of a configured server. Surfaces that -/// render connection/authentication state (list, summary, menu, trace, model -/// catalog, capability search) derive from this classification instead of -/// re-classifying the raw axes; menus may additionally model their own axes -/// (reloading, trust admission) on top. +/// The single status of a configured server, from its connection and +/// authentication. pub const Status = enum { disabled, connecting, @@ -68,111 +53,6 @@ pub fn classify( }; } -pub const CacheFreshness = enum { - unavailable, - fresh, - stale, - refreshing, - failed_refresh, -}; - -pub const SubscriptionState = enum { - unavailable, - starting, - active, - unsupported, - stopped, -}; - -pub const CapabilityCounts = struct { - tools: ?usize = null, - resources: ?usize = null, - resource_templates: ?usize = null, - prompts: ?usize = null, -}; - -pub fn capabilityCount(advertised: bool, loaded: bool, count: usize) ?usize { - if (!advertised) return 0; - return if (loaded) count else null; -} - -pub const SubscriptionObservation = struct { - present: bool, - unsupported: bool = false, - finished: bool = false, - requires_acknowledgement: bool = false, - acknowledged: bool = false, -}; - -pub fn subscriptionStateFor(observation: SubscriptionObservation) SubscriptionState { - if (!observation.present) return .unavailable; - if (observation.unsupported) return .unsupported; - if (observation.finished) return .stopped; - if (!observation.requires_acknowledgement or observation.acknowledged) return .active; - return .starting; -} - -pub const ServerSnapshot = struct { - configured_name: []u8, - identity_name: ?[]u8 = null, - negotiated_name: ?[]u8, - negotiated_version: ?[]u8, - source: mcp_contract.ConfigSource, - scope: mcp_contract.ConfigScope, - workspace_admission: ?mcp_contract.WorkspaceAdmission = null, - required: bool, - transport: mcp_contract.McpTransport, - protocol_version: ?[]u8, - connection: ConnectionState, - reloading: bool = false, - authentication: AuthenticationState, - counts: CapabilityCounts, - cache_freshness: CacheFreshness, - subscription: SubscriptionState, - runtime_generation: u64, - catalog_generation: u64, - retry_attempt: u8, - retry_in_ms: ?u64, - last_successful_discovery_ms: ?u64, - failure: ?[]u8, - - pub fn deinit(self: *ServerSnapshot, alloc: Allocator) void { - alloc.free(self.configured_name); - if (self.identity_name) |name| alloc.free(name); - if (self.negotiated_name) |value| alloc.free(value); - if (self.negotiated_version) |value| alloc.free(value); - if (self.protocol_version) |value| alloc.free(value); - if (self.failure) |value| alloc.free(value); - self.* = undefined; - } - - /// Borrows the exact configured identity for this snapshot's lifetime. - pub fn identity(self: *const ServerSnapshot) []const u8 { - return self.identity_name orelse self.configured_name; - } -}; - -pub fn sameState(a: Snapshot, b: Snapshot) bool { - if (a.servers.len != b.servers.len or a.configuration_issues.len != b.configuration_issues.len) return false; - for (a.configuration_issues, b.configuration_issues) |left, right| { - if (!std.mem.eql(u8, left.message, right.message)) return false; - } - for (a.servers, b.servers) |left, right| { - const left_text = [_]?[]const u8{ left.identity(), left.configured_name, left.negotiated_name, left.negotiated_version, left.protocol_version, left.failure }; - const right_text = [_]?[]const u8{ right.identity(), right.configured_name, right.negotiated_name, right.negotiated_version, right.protocol_version, right.failure }; - for (left_text, right_text) |x, y| { - if (x) |value| { - if (y == null or !std.mem.eql(u8, value, y.?)) return false; - } else if (y != null) return false; - } - if (!std.meta.eql( - .{ left.source, left.scope, left.workspace_admission, left.required, left.transport, left.connection, left.reloading, left.authentication, left.counts, left.cache_freshness, left.subscription, left.runtime_generation, left.catalog_generation, left.retry_attempt, left.retry_in_ms, left.last_successful_discovery_ms }, - .{ right.source, right.scope, right.workspace_admission, right.required, right.transport, right.connection, right.reloading, right.authentication, right.counts, right.cache_freshness, right.subscription, right.runtime_generation, right.catalog_generation, right.retry_attempt, right.retry_in_ms, right.last_successful_discovery_ms }, - )) return false; - } - return true; -} - pub const ConfigurationIssue = struct { message: []u8, @@ -209,20 +89,6 @@ pub const LocalConfigSnapshot = struct { } }; -pub const Snapshot = struct { - captured_at_ms: u64, - servers: []ServerSnapshot, - configuration_issues: []ConfigurationIssue = &.{}, - - pub fn deinit(self: *Snapshot, alloc: Allocator) void { - for (self.servers) |*server| server.deinit(alloc); - alloc.free(self.servers); - for (self.configuration_issues) |*issue| issue.deinit(alloc); - alloc.free(self.configuration_issues); - self.* = undefined; - } -}; - pub const LocalConfigInspection = struct { profile_diagnostic: mcp_contract.ProfileConfigDiagnostic = .clear, snapshot: LocalConfigSnapshot, @@ -251,432 +117,6 @@ pub fn inspectLocalConfigUnavailable( } }; } -pub const StartupDecision = enum { - ready, - degraded, - blocked, -}; - -pub fn startupDecision(servers: []const ServerSnapshot) StartupDecision { - var degraded = false; - for (servers) |server| { - const status = classify(server.connection, server.authentication, false); - if (status == .ready or status == .disabled and !server.required) continue; - if (server.required) return .blocked; - degraded = true; - } - return if (degraded) .degraded else .ready; -} - -fn publishCandidate(servers: []const ServerSnapshot) bool { - return startupDecision(servers) != .blocked; -} - -pub fn publishCandidateForDecision(decision: StartupDecision) bool { - return decision != .blocked; -} - -pub fn render(alloc: Allocator, snapshot: Snapshot) ![]u8 { - if (snapshot.servers.len == 0 and snapshot.configuration_issues.len == 0) { - return alloc.dupe(u8, "No MCP servers configured.\n"); - } - var out: std.Io.Writer.Allocating = .init(alloc); - defer out.deinit(); - if (snapshot.servers.len > 0) { - try out.writer.print("MCP health ({d} {s}):\n", .{ - snapshot.servers.len, - if (snapshot.servers.len == 1) "server" else "servers", - }); - } - for (snapshot.servers) |server| { - try out.writer.print( - " {s} source={s} scope={s} policy={s} transport={s} state={s} auth={s} status={s}\n", - .{ - server.configured_name, - @tagName(server.source), - @tagName(server.scope), - if (server.required) "required" else "optional", - @tagName(server.transport), - @tagName(server.connection), - @tagName(server.authentication), - @tagName(classify(server.connection, server.authentication, false)), - }, - ); - if (server.workspace_admission) |admission| { - try out.writer.print(" admission={s}\n", .{@tagName(admission)}); - } - try out.writer.print( - " negotiated_name={s} negotiated_version={s} protocol={s}\n", - .{ - server.negotiated_name orelse "unavailable", - server.negotiated_version orelse "unavailable", - server.protocol_version orelse "unavailable", - }, - ); - try out.writer.writeAll(" tools="); - try writeOptionalCount(&out.writer, server.counts.tools); - try out.writer.writeAll(" resources="); - try writeOptionalCount(&out.writer, server.counts.resources); - try out.writer.writeAll(" templates="); - try writeOptionalCount(&out.writer, server.counts.resource_templates); - try out.writer.writeAll(" prompts="); - try writeOptionalCount(&out.writer, server.counts.prompts); - try out.writer.print( - " cache={s} subscription={s}\n", - .{ @tagName(server.cache_freshness), @tagName(server.subscription) }, - ); - try out.writer.print(" retry_attempt={d} retry_in_ms=", .{server.retry_attempt}); - if (server.retry_in_ms) |value| try out.writer.print("{d}", .{value}) else try out.writer.writeAll("none"); - try out.writer.print( - " discovery={s}", - .{if (server.last_successful_discovery_ms == null) "pending" else "completed"}, - ); - try out.writer.writeByte('\n'); - if (server.failure) |failure| try out.writer.print(" failure={s}\n", .{failure}); - } - if (snapshot.configuration_issues.len > 0) { - try out.writer.writeAll("Project MCP configuration errors:\n"); - for (snapshot.configuration_issues) |issue| { - try out.writer.print(" {s}\n", .{issue.message}); - } - } - return out.toOwnedSlice(); -} - -pub fn renderSummary(alloc: Allocator, snapshot: Snapshot) ![]u8 { - if (snapshot.servers.len == 0 and snapshot.configuration_issues.len == 0) { - return alloc.dupe( - u8, - "MCP: no servers configured. Use /mcp add [args...].", - ); - } - if (snapshot.servers.len == 0) { - return alloc.print( - "MCP: {d} project .mcp.json {s}. Use /mcp list for details.", - .{ - snapshot.configuration_issues.len, - if (snapshot.configuration_issues.len == 1) "error" else "errors", - }, - ); - } - var ready: usize = 0; - var connecting: usize = 0; - var auth_required: usize = 0; - var failed: usize = 0; - var first_auth_server: ?[]const u8 = null; - var pending_names: [max_pending_summary_names][]const u8 = undefined; - var pending_count: usize = 0; - for (snapshot.servers) |server| { - if (server.source == .workspace and - server.workspace_admission == .pending) - { - if (pending_count < pending_names.len) { - pending_names[pending_count] = server.configured_name; - } - pending_count += 1; - } - switch (classify(server.connection, server.authentication, false)) { - .needs_auth => { - auth_required += 1; - if (first_auth_server == null) first_auth_server = server.configured_name; - }, - .ready => ready += 1, - .connecting => connecting += 1, - .failed => failed += 1, - .disabled, .unavailable, .on_demand => {}, - } - } - - var out: std.Io.Writer.Allocating = .init(alloc); - errdefer out.deinit(); - try out.writer.print( - "MCP: {d} {s} — {d} ready, {d} connecting, {d} needs auth, {d} failed.", - .{ - snapshot.servers.len, - if (snapshot.servers.len == 1) "server" else "servers", - ready, - connecting, - auth_required, - failed, - }, - ); - if (first_auth_server) |name| { - try out.writer.print(" Run /mcp auth {s} --open.", .{name}); - } - if (pending_count > 0) { - try out.writer.writeAll(" Pending approval: "); - const rendered_count = @min(pending_count, pending_names.len); - for (pending_names[0..rendered_count], 0..) |name, index| { - if (index > 0) try out.writer.writeAll(", "); - var encoded = try text_utils.encodeTerminalSafe(alloc, name, 128); - defer encoded.deinit(alloc); - try out.writer.writeAll(encoded.bytes); - } - if (pending_count > rendered_count) { - try out.writer.print(", +{d} more", .{pending_count - rendered_count}); - } - try out.writer.writeByte('.'); - } - if (snapshot.configuration_issues.len > 0) { - try out.writer.print( - " Project .mcp.json errors: {d}.", - .{snapshot.configuration_issues.len}, - ); - } - try out.writer.writeAll(" Use /mcp list for details."); - return out.toOwnedSlice(); -} - -fn writeOptionalCount(writer: *std.Io.Writer, count: ?usize) !void { - if (count) |value| try writer.print("{d}", .{value}) else try writer.writeAll("unknown"); -} - -/// One-line startup notice naming servers that need attention once discovery -/// settles, or null when nothing needs the user. Derived from the same -/// classification as every other surface. -pub fn renderStartupNotice(alloc: Allocator, snapshot: Snapshot) !?[]u8 { - var needs_auth: usize = 0; - var failed: usize = 0; - var first_auth: ?[]const u8 = null; - for (snapshot.servers) |server| { - switch (classify(server.connection, server.authentication, false)) { - .needs_auth => { - needs_auth += 1; - if (first_auth == null) first_auth = server.configured_name; - }, - .failed => failed += 1, - .disabled, .connecting, .ready, .unavailable, .on_demand => {}, - } - } - if (needs_auth == 0 and failed == 0) return null; - - var out: std.Io.Writer.Allocating = .init(alloc); - errdefer out.deinit(); - try out.writer.print("MCP startup: {d} server{s} need{s} attention", .{ - needs_auth + failed, - if (needs_auth + failed == 1) "" else "s", - if (needs_auth + failed == 1) "s" else "", - }); - if (needs_auth > 0) { - try out.writer.print(", {d} need{s} authentication", .{ - needs_auth, - if (needs_auth == 1) "s" else "", - }); - } - if (failed > 0) { - try out.writer.print(", {d} failed", .{failed}); - } - try out.writer.writeByte('.'); - if (first_auth) |name| { - var encoded = try text_utils.encodeTerminalSafe(alloc, name, 128); - defer encoded.deinit(alloc); - try out.writer.print(" Run /mcp auth {s} --open.", .{encoded.bytes}); - } - try out.writer.writeAll(" Use /mcp list for details."); - return try out.toOwnedSlice(); -} - -test "required health blocks while optional health degrades" { - var snapshots = [_]ServerSnapshot{emptyServerSnapshot()}; - snapshots[0].connection = .failed; - try std.testing.expectEqual(StartupDecision.degraded, startupDecision(&snapshots)); - snapshots[0].required = true; - try std.testing.expectEqual(StartupDecision.blocked, startupDecision(&snapshots)); - try std.testing.expect(!publishCandidate(&snapshots)); -} - -test "health decisions distinguish lazy capabilities and listener acknowledgement" { - try std.testing.expectEqual(@as(?usize, null), capabilityCount(true, false, 0)); - try std.testing.expectEqual(@as(?usize, 0), capabilityCount(false, false, 0)); - try std.testing.expectEqual(@as(?usize, 7), capabilityCount(true, true, 7)); - - const cases = [_]struct { - observation: SubscriptionObservation, - expected: SubscriptionState, - }{ - .{ .observation = .{ .present = false }, .expected = .unavailable }, - .{ .observation = .{ .present = true, .unsupported = true }, .expected = .unsupported }, - .{ .observation = .{ .present = true, .finished = true }, .expected = .stopped }, - .{ .observation = .{ .present = true }, .expected = .active }, - .{ .observation = .{ .present = true, .requires_acknowledgement = true }, .expected = .starting }, - .{ .observation = .{ .present = true, .requires_acknowledgement = true, .acknowledged = true }, .expected = .active }, - }; - for (cases) |case| { - try std.testing.expectEqual(case.expected, subscriptionStateFor(case.observation)); - } -} - -test "health derives failed connections from transport state" { - try std.testing.expectEqual( - ConnectionState.failed, - observedConnection(.ready, false), - ); - try std.testing.expectEqual( - ConnectionState.ready, - observedConnection(.ready, true), - ); - try std.testing.expectEqual( - ConnectionState.ready, - observedConnection(.ready, null), - ); - try std.testing.expectEqual(@as(?u64, 250), retryDelay(1_250, 1_000)); - try std.testing.expectEqual(@as(?u64, 0), retryDelay(900, 1_000)); - try std.testing.expectEqual(@as(?u64, null), retryDelay(null, 1_000)); -} - -test "health rendering includes complete typed state without secret-bearing configuration" { - const alloc = std.testing.allocator; - var server = emptyServerSnapshot(); - server.configured_name = try alloc.dupe(u8, "server\\u001b[31m"); - server.negotiated_name = try alloc.dupe(u8, "fixture"); - server.negotiated_version = try alloc.dupe(u8, "1.2.3"); - server.protocol_version = try alloc.dupe(u8, "2026-07-28"); - server.source = .profile; - server.scope = .profile; - server.required = true; - server.transport = .http; - server.connection = .failed; - server.authentication = .required; - server.counts = .{ .tools = 2, .resources = 3, .resource_templates = 4, .prompts = 5 }; - server.cache_freshness = .failed_refresh; - server.subscription = .stopped; - server.runtime_generation = 7; - server.catalog_generation = 9; - server.retry_attempt = 2; - server.retry_in_ms = 123; - server.last_successful_discovery_ms = 100; - server.failure = try alloc.dupe(u8, "Authentication is required; run /mcp auth --open."); - var snapshot = Snapshot{ .captured_at_ms = 200, .servers = try alloc.dupe(ServerSnapshot, &.{server}) }; - defer snapshot.deinit(alloc); - - const output = try render(alloc, snapshot); - defer alloc.free(output); - for ([_][]const u8{ - "source=profile", - "scope=profile", - "policy=required", - "transport=http", - "state=failed", - "auth=required", - "tools=2 resources=3 templates=4 prompts=5", - "MCP health (1 server)", - "retry_attempt=2 retry_in_ms=123 discovery=completed", - }) |expected| try std.testing.expect(std.mem.find(u8, output, expected) != null); - try std.testing.expect(std.mem.find(u8, output, "captured_at_ms") == null); - try std.testing.expect(std.mem.find(u8, output, "runtime_generation") == null); - try std.testing.expect(std.mem.find(u8, output, "catalog_generation") == null); - try std.testing.expect(std.mem.find(u8, output, "Authorization") == null); - try std.testing.expect(std.mem.find(u8, output, "https://") == null); -} - -test "compact health summary reports actionable aggregate state" { - const alloc = std.testing.allocator; - const empty = Snapshot{ .captured_at_ms = 0, .servers = &.{} }; - const empty_output = try renderSummary(alloc, empty); - defer alloc.free(empty_output); - try std.testing.expectEqualStrings( - "MCP: no servers configured. Use /mcp add [args...].", - empty_output, - ); - - var servers = [_]ServerSnapshot{ - emptyServerSnapshot(), - emptyServerSnapshot(), - emptyServerSnapshot(), - emptyServerSnapshot(), - }; - servers[0].configured_name = @constCast("ready"); - servers[0].connection = .ready; - servers[1].configured_name = @constCast("loading"); - servers[1].connection = .connecting; - servers[2].configured_name = @constCast("plain"); - servers[2].connection = .failed; - servers[2].authentication = .required; - servers[3].configured_name = @constCast("broken"); - servers[3].connection = .failed; - const summary = try renderSummary(alloc, .{ .captured_at_ms = 0, .servers = &servers }); - defer alloc.free(summary); - try std.testing.expectEqualStrings( - "MCP: 4 servers — 1 ready, 1 connecting, 1 needs auth, 1 failed. Run /mcp auth plain --open. Use /mcp list for details.", - summary, - ); -} - -test "compact health summary names pending workspace servers" { - const alloc = std.testing.allocator; - var servers = [_]ServerSnapshot{ - emptyServerSnapshot(), - emptyServerSnapshot(), - emptyServerSnapshot(), - }; - servers[0].configured_name = @constCast("docs"); - servers[0].source = .workspace; - servers[0].scope = .workspace; - servers[0].workspace_admission = .pending; - servers[1].configured_name = @constCast("db"); - servers[1].source = .workspace; - servers[1].scope = .workspace; - servers[1].workspace_admission = .pending; - servers[2].configured_name = @constCast("approved"); - servers[2].source = .workspace; - servers[2].scope = .workspace; - servers[2].workspace_admission = .approved; - - const summary = try renderSummary(alloc, .{ - .captured_at_ms = 0, - .servers = &servers, - }); - defer alloc.free(summary); - try std.testing.expect(std.mem.find( - u8, - summary, - "Pending approval: docs, db.", - ) != null); - try std.testing.expect(std.mem.find(u8, summary, "Pending approval: approved") == null); -} - -test "health equality compares values and ignores capture time" { - const alloc = std.testing.allocator; - var left = emptyServerSnapshot(); - left.configured_name = try alloc.dupe(u8, "plain"); - defer left.deinit(alloc); - var right = emptyServerSnapshot(); - right.configured_name = try alloc.dupe(u8, "plain"); - defer right.deinit(alloc); - const a: Snapshot = .{ .captured_at_ms = 1, .servers = @as(*[1]ServerSnapshot, &left) }; - const b: Snapshot = .{ .captured_at_ms = 200, .servers = @as(*[1]ServerSnapshot, &right) }; - try std.testing.expect(sameState(a, b)); - right.reloading = true; - try std.testing.expect(!sameState(a, b)); - right.reloading = false; - right.connection = .failed; - try std.testing.expect(!sameState(a, b)); -} - -fn emptyServerSnapshot() ServerSnapshot { - return .{ - .configured_name = &.{}, - .negotiated_name = null, - .negotiated_version = null, - .source = .profile, - .scope = .profile, - .required = false, - .transport = .stdio, - .protocol_version = null, - .connection = .disconnected, - .authentication = .none, - .counts = .{}, - .cache_freshness = .unavailable, - .subscription = .unavailable, - .runtime_generation = 0, - .catalog_generation = 0, - .retry_attempt = 0, - .retry_in_ms = null, - .last_successful_discovery_ms = null, - .failure = null, - }; -} - test "classify applies one precedence rule for every surface" { const Case = struct { connection: ConnectionState, @@ -704,83 +144,3 @@ test "classify applies one precedence rule for every surface" { ); } } - -test "startupDecision keeps blocking required servers that need authentication" { - var required_auth = emptyServerSnapshot(); - required_auth.required = true; - required_auth.connection = .failed; - required_auth.authentication = .required; - var servers = [_]ServerSnapshot{required_auth}; - try std.testing.expectEqual(StartupDecision.blocked, startupDecision(&servers)); - - var optional_auth = emptyServerSnapshot(); - optional_auth.connection = .failed; - optional_auth.authentication = .required; - var optional_servers = [_]ServerSnapshot{optional_auth}; - try std.testing.expectEqual(StartupDecision.degraded, startupDecision(&optional_servers)); - - var ready_server = emptyServerSnapshot(); - ready_server.required = true; - ready_server.connection = .ready; - ready_server.authentication = .authenticated; - var ready_servers = [_]ServerSnapshot{ ready_server, optional_auth }; - try std.testing.expectEqual(StartupDecision.degraded, startupDecision(&ready_servers)); - - // A ready server with a freshly observed auth challenge classifies as - // needs_auth, so a required one still blocks and an optional one degrades. - var ready_challenged = emptyServerSnapshot(); - ready_challenged.required = true; - ready_challenged.connection = .ready; - ready_challenged.authentication = .required; - try std.testing.expectEqual(Status.needs_auth, classify(ready_challenged.connection, ready_challenged.authentication, false)); - var challenged_servers = [_]ServerSnapshot{ready_challenged}; - try std.testing.expectEqual(StartupDecision.blocked, startupDecision(&challenged_servers)); -} - -test "render prints the classified status next to the raw axes" { - const alloc = std.testing.allocator; - var server = emptyServerSnapshot(); - server.connection = .failed; - server.authentication = .required; - var snapshot = Snapshot{ .captured_at_ms = 0, .servers = try alloc.dupe(ServerSnapshot, &.{server}) }; - defer snapshot.deinit(alloc); - - const output = try render(alloc, snapshot); - defer alloc.free(output); - - try std.testing.expect(std.mem.find(u8, output, "state=failed") != null); - try std.testing.expect(std.mem.find(u8, output, "auth=required") != null); - try std.testing.expect(std.mem.find(u8, output, "status=needs_auth") != null); -} - -test "renderStartupNotice names needs-auth servers and stays quiet when healthy" { - const alloc = std.testing.allocator; - var healthy = emptyServerSnapshot(); - healthy.connection = .ready; - var healthy_servers = [_]ServerSnapshot{healthy}; - const quiet = try renderStartupNotice(alloc, .{ .captured_at_ms = 0, .servers = &healthy_servers }); - try std.testing.expectEqual(@as(?[]u8, null), quiet); - - var auth = emptyServerSnapshot(); - auth.configured_name = @constCast("linear"); - auth.connection = .failed; - auth.authentication = .required; - var broken = emptyServerSnapshot(); - broken.configured_name = @constCast("down"); - broken.connection = .failed; - var servers = [_]ServerSnapshot{ auth, broken }; - const notice = (try renderStartupNotice(alloc, .{ .captured_at_ms = 0, .servers = &servers })).?; - defer alloc.free(notice); - try std.testing.expectEqualStrings( - "MCP startup: 2 servers need attention, 1 needs authentication, 1 failed. Run /mcp auth linear --open. Use /mcp list for details.", - notice, - ); - - var single_servers = [_]ServerSnapshot{broken}; - const single = (try renderStartupNotice(alloc, .{ .captured_at_ms = 0, .servers = &single_servers })).?; - defer alloc.free(single); - try std.testing.expectEqualStrings( - "MCP startup: 1 server needs attention, 1 failed. Use /mcp list for details.", - single, - ); -} diff --git a/src/core/mcp/project_config.zig b/src/core/mcp/project_config.zig index f8d36ec30..f8575e908 100644 --- a/src/core/mcp/project_config.zig +++ b/src/core/mcp/project_config.zig @@ -613,7 +613,7 @@ pub fn expandApprovedWorkspaceConfigs( return expandConfigs(alloc, &result.configs, &result.diagnostics, environment, false); } -/// Expands `${VAR}` in profile servers too, as MCP-v2 does; a +/// Expands `${VAR}` in profile servers too, as the MCP host does; a /// server that can't be expanded is dropped /// with a diagnostic, as in `.mcp.json`. pub fn expandProfileConfigs( diff --git a/src/core/mcp_host/config.zig b/src/core/mcp_host/config.zig index 845c6ca76..d781a201c 100644 --- a/src/core/mcp_host/config.zig +++ b/src/core/mcp_host/config.zig @@ -1,4 +1,4 @@ -//! fx's MCP servers as MCP-v2 runs them. Each config from `builtins/mcp.zig` +//! fx's MCP servers as the engine runs them. Each config from `builtins/mcp.zig` //! `loadNativeConfigs` (profile first, then the project's) becomes an //! `mcp.engine.Config`, or is held back with the reason, which fx shows. @@ -19,7 +19,7 @@ pub const Held = enum { waiting_for_approval, /// A project server the user rejected. rejected, - /// `"type": "sse"`: HTTP+SSE is deprecated and MCP-v2 doesn't speak it. + /// `"type": "sse"`: HTTP+SSE is deprecated and the engine doesn't speak it. /// `"type": "acp"` from an editor: a draft that ACP v1 doesn't define. unsupported, /// A header, token, or client secret names an environment variable that isn't set. @@ -144,7 +144,7 @@ pub fn resolve( if (s.slack_app) { if (s.callback_port == null) s.callback_port = slack.callback_port; if (auth.scopes == null) auth.scopes = slack.scopes; - // Slack's tokens are for its origin with a trailing slash; MCP-v2 + // Slack's tokens are for its origin with a trailing slash; the engine // takes it only if it is the server's own. if (auth.resource == null) if (s.url) |url| { auth.resource = try arena.print("{s}/", .{origin(url)}); diff --git a/src/core/mcp_host/credentials.zig b/src/core/mcp_host/credentials.zig index 166ca03c5..1c6c7ed0d 100644 --- a/src/core/mcp_host/credentials.zig +++ b/src/core/mcp_host/credentials.zig @@ -1,11 +1,11 @@ -//! Sign-in credentials MCP-v2 hands fx to keep (AUTH-21): one opaque blob per +//! Sign-in credentials the engine hands fx to keep (AUTH-21): one opaque blob per //! server name and URL, so a changed URL never gets an old token. They live in //! the macOS Keychain, or in a private file under `~/.fx/mcp-credentials/`. //! Either way the directory's advisory lock is held while the store is read //! and rewritten, so two fx processes don't overwrite each other's entries. //! A store this fx can't read, damaged or saved by a newer fx, is never -//! written over. v1's logins are never taken over: v1 and -//! MCP-v2 refreshing one login apart, neither seeing the other's saves, would +//! written over. v1's logins are never taken over: an older fx and this +//! one refreshing one login apart, neither seeing the other's saves, would //! sign it out. One fx refreshes a login at a time, under that login's //! refresh lock. //! @@ -379,7 +379,7 @@ test "a workspace server's credential is its workspace's own, apart from the pro try testing.expectEqualStrings("{\"access\":\"mine\"}", still); } -/// v1's store with a login for linear, and one MCP-v2 can't use. +/// v1's store with a login for linear, and one the engine can't use. fn writeV1(home: []const u8) !void { var home_dir = io_mod.VerifiedDir{ .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) }; defer home_dir.close(); diff --git a/src/core/mcp_host/host.zig b/src/core/mcp_host/host.zig index e949c7bce..34231de16 100644 --- a/src/core/mcp_host/host.zig +++ b/src/core/mcp_host/host.zig @@ -1,6 +1,5 @@ -//! fx's side of MCP-v2 in one place: the runtime that owns the -//! engine, and the catalog the model sees. Chosen with FX_MCP_ENGINE=v2 -//! until v1 is removed. +//! fx's side of the MCP engine in one place: the runtime that owns the +//! engine, and the catalog the model sees. const std = @import("std"); const builtin = @import("builtin"); @@ -19,7 +18,6 @@ pub const Catalog = model.Catalog; pub const Notify = runtime_mod.Notify; pub const Notice = runtime_mod.Notice; pub const Status = runtime_mod.Status; -pub const selected = runtime_mod.selected; pub const signalServersForExit = runtime_mod.signalServersForExit; pub const waitRetired = runtime_mod.waitRetired; pub const menu = @import("menu.zig"); @@ -33,7 +31,7 @@ pub const Extras = struct { slash_commands: bool = false, }; -/// Loads the host for a workspace, or null when MCP-v2 isn't selected or no +/// Loads the host for a workspace, or null when there is no home folder or no /// server is configured. pub const LoadFn = *const fn ( gpa: Allocator, diff --git a/src/core/mcp_host/login.zig b/src/core/mcp_host/login.zig index 4ff8dc727..e43fbe2d3 100644 --- a/src/core/mcp_host/login.zig +++ b/src/core/mcp_host/login.zig @@ -1,6 +1,6 @@ //! The loopback redirect for an MCP sign-in (RFC 8252 §7.3): fx listens on //! 127.0.0.1, the browser comes back to `GET /callback`, and the address it -//! came to goes to MCP-v2's `finishSignIn`. Slack's app allows only a +//! came to goes to the engine's `finishSignIn`. Slack's app allows only a //! `localhost` redirect, so for it the redirect and that address say //! `localhost`, which browsers reach on 127.0.0.1. //! @@ -98,7 +98,7 @@ pub const Listener = struct { const Waiting = struct { listener: *Listener, io: std.Io }; -/// The browser's return: the address for MCP-v2, and whether it refused. +/// The browser's return: the address for the engine, and whether it refused. const Return = struct { url: []u8, refused: bool }; /// `GET /callback` with the sign-in's state; anything else is unrelated. @@ -108,7 +108,7 @@ fn takeReturn(context: ?*anyopaque, alloc: Allocator, target: []const u8) browse const query = target[query_at + 1 ..]; if (!std.mem.eql(u8, target[0..query_at], "/callback") or !w.listener.isOwn(w.io, query)) return .unrelated; const url = alloc.print("{s}{d}{s}", .{ w.listener.prefix(), w.listener.port, target }) catch |err| return .{ .failed = err }; - // A refusal comes back with an `error` (RFC 6749 §4.1.2.1); MCP-v2 reads + // A refusal comes back with an `error` (RFC 6749 §4.1.2.1); the engine reads // the rest from the address. return .{ .accepted = .{ .url = url, .refused = hasParam(query, "error") } }; } diff --git a/src/core/mcp_host/model.zig b/src/core/mcp_host/model.zig index 034905d18..32f725430 100644 --- a/src/core/mcp_host/model.zig +++ b/src/core/mcp_host/model.zig @@ -1,8 +1,8 @@ -//! What the model sees of MCP-v2: each server's tools under the +//! What the model sees of the MCP engine: each server's tools under the //! names fx gives them, and the has, validate, call, search, schema, and //! snapshot operations fx's tool runtime asks for. The tool lists come from //! the runtime; parsing, names, bindings, ranking, schemas, and call results -//! are fx's own MCP code, so the model sees an MCP-v2 server as it saw v1's. +//! are fx's own MCP code. const std = @import("std"); const runtime_mod = @import("runtime.zig"); @@ -883,7 +883,7 @@ test "a subagent made before its parent's server starts may use the server's too try testing.expectError(error.McpAccessDenied, c.callTool(a, "mcp_fake_echo", "{}", 4096, .{ .access = stale })); } -test "an MCP-v2 server's tools reach the model under fx's names" { +test "an MCP server's tools reach the model under fx's names" { var f: Fixture = undefined; try f.init(); defer f.deinit(); diff --git a/src/core/mcp_host/runtime.zig b/src/core/mcp_host/runtime.zig index 388358c9c..561b0e039 100644 --- a/src/core/mcp_host/runtime.zig +++ b/src/core/mcp_host/runtime.zig @@ -1,9 +1,8 @@ -//! fx's side of MCP-v2. One thread owns the engine, which is +//! fx's side of the MCP engine. One thread owns the engine, which is //! single-threaded: other threads send it requests through a queue, wake it, //! and read copied status snapshots. The owner turns fx's MCP config into the //! engine's servers (`config.zig`), keeps sign-in credentials //! (`credentials.zig`), and runs logins on a loopback listener (`login.zig`). -//! Chosen with FX_MCP_ENGINE=v2 until v1 is removed. //! //! TLA+ models: `tests/tla/mcp_host/HostLifecycle.tla`, //! `tests/tla/mcp_host/SignIn.tla`, `tests/tla/mcp_host/SharedLogins.tla`, and @@ -32,7 +31,7 @@ const engine = mcp.engine; const login_timeout_ms: i64 = 5 * 60 * 1000; /// The longest the owner sleeps; a `wake` ends it sooner. const idle_wait_ms: u32 = 1000; -/// How long a logout waits for its revocation, as MCP-v2's `close` does. +/// How long a logout waits for its revocation, as the engine's `close` does. const revoke_wait_ms: i64 = 2000; /// How long a refresh waits for another fx refreshing the same login; then /// it refreshes anyway. @@ -71,11 +70,6 @@ pub fn waitRetired() void { while (retiring.load(.acquire) != 0) io_mod.sleep(5 * std.time.ns_per_ms); } -/// Every host runs its MCP servers through MCP-v2. -pub fn selected() bool { - return true; -} - pub const State = enum { idle, connecting, @@ -317,7 +311,7 @@ const Login = struct { deadline_ms: i64, open_browser: bool, /// The address the browser came back to, kept until the login ends: - /// MCP-v2 reads the code in it when it redeems it. + /// The engine reads the code in it when it redeems it. callback: ?[]u8 = null, }; @@ -379,7 +373,7 @@ pub const Runtime = struct { not_saved: bool = false, }; - /// A refresh MCP-v2 said was due: waiting for its login's refresh lock, + /// A refresh the engine said was due: waiting for its login's refresh lock, /// or holding it until the refresh ends. const Refresh = struct { server: usize, @@ -1755,7 +1749,7 @@ fn declaredCapabilities(questions: elicitation.Capabilities) []const u8 { return "{}"; } -/// MCP-v2 names why a server is down; fx says it in words. Anything else, +/// The engine names why a server is down; fx says it in words. Anything else, /// such as an HTTP failure's own text, is shown as it is. /// Why a new login wasn't saved, in words. fn notSavedReason(buffer: []u8, store: credentials.Store, err: anyerror) []const u8 { diff --git a/src/core/mcp_host/slack.zig b/src/core/mcp_host/slack.zig index b877d6ae8..dad7c9ca7 100644 --- a/src/core/mcp_host/slack.zig +++ b/src/core/mcp_host/slack.zig @@ -2,7 +2,7 @@ //! with the redirect `http://localhost:3118/callback`. Slack refreshes a PKCE //! app's tokens without the app's secret only for a localhost redirect //! (Slack's "Using PKCE"), so a login made through any other one lasts only -//! until its first token expires. MCP-v2 signs in as for any server, asking +//! until its first token expires. The engine signs in as for any server, asking //! for the scopes the app has. const std = @import("std"); diff --git a/src/core/slash_commands/command_specs.zig b/src/core/slash_commands/command_specs.zig index b4acf882e..5d50490e8 100644 --- a/src/core/slash_commands/command_specs.zig +++ b/src/core/slash_commands/command_specs.zig @@ -1,8 +1,5 @@ const std = @import("std"); const io_mod = @import("../shared/io.zig"); - -pub const mcp_add_usage = "mcp add slack | mcp add NAME COMMAND [ARGS...] | mcp add --transport http NAME URL"; -pub const mcp_auth_usage = "mcp auth NAME"; const display_width = @import("../shared/display_width.zig"); const list_window = @import("../shared/list_window.zig"); const mod_registry = @import("../mods/registry.zig"); @@ -869,7 +866,7 @@ const allowlist_user_add_tool_completions = scopedAllowlistCompletions("user", a const allowlist_local_remove_tool_completions = scopedAllowlistCompletions("local", allowlist_remove_tool_completions); const allowlist_user_remove_tool_completions = scopedAllowlistCompletions("user", allowlist_remove_tool_completions); -/// `/mcp` completes MCP-v2's verbs and server names. +/// `/mcp` completes the MCP verbs and server names. fn mcpVerbsOn() bool { return true; } @@ -2205,7 +2202,7 @@ pub const slack_install_spec: TopLevelSpec = .{ .details = &.{ "install opens Slack authorization through fx.sh and saves the bot credentials on this computer.", "status shows local installation metadata; refresh renews tokens without browser authorization.", - "This is a workspace bot installer. Employee MCP login remains fx mcp auth NAME.", + "This is a workspace bot installer. Employee MCP login remains fx mcp login NAME.", "Credentials are saved in ~/.fx/slack/installation.json with owner-only permissions.", "Refresh runs only when requested; no background service is installed.", }, diff --git a/src/main.zig b/src/main.zig index b8d04b4fd..03bb75f06 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1630,10 +1630,6 @@ const App = struct { try self.mcp.loadHost(load, self.alloc, self.workspace_root, self.toolRegistry(), .{ .form = true, .url = true }); } - pub fn mcpHostSelected(_: *const App) bool { - return mcp_host.selected(); - } - pub fn runMcpVerbs(self: *App, rest: []const u8) !void { try self.mcp.runVerbs(io_mod.homeDir() orelse "", self.workspace_root, rest); } @@ -3497,7 +3493,7 @@ fn needsEarlyThreadedIo(args: []const [:0]const u8) bool { std.mem.eql(u8, effective_args[1], "auth") or std.mem.eql(u8, effective_args[1], "list") or std.mem.eql(u8, effective_args[1], "logout") or - // MCP-v2's verbs start a host or delete a stored login. + // The MCP verbs start a host or delete a stored login. std.mem.eql(u8, effective_args[1], "show") or std.mem.eql(u8, effective_args[1], "login") or std.mem.eql(u8, effective_args[1], "remove") or diff --git a/src/ui/footer/mcp_host_menu_presentation.zig b/src/ui/footer/mcp_host_menu_presentation.zig index 5d6398404..862c30d82 100644 --- a/src/ui/footer/mcp_host_menu_presentation.zig +++ b/src/ui/footer/mcp_host_menu_presentation.zig @@ -1,4 +1,4 @@ -//! Draws the `/mcp` menu on MCP-v2 as fx's picker: a +//! Draws the `/mcp` menu as fx's picker: a //! titled list, a server's facts, choices, and tools, and fx's hint line. //! The logic is `core/mcp_host/menu.zig`; this only turns it into rows. diff --git a/tests/e2e/acp.test.ts b/tests/e2e/acp.test.ts index 26b16dd96..a76f4b6af 100644 --- a/tests/e2e/acp.test.ts +++ b/tests/e2e/acp.test.ts @@ -2861,15 +2861,15 @@ describe("acp: model-independent", () => { name: MCP_TOOL_NAME, }), fakeGatewayToolCall("call_http", MCP_TOOL_NAME, { text: "acp" }), - finalText("ACP MCP-v2 complete"), + finalText("ACP MCP complete"), ]); try { client = await AcpClient.create({ cwd: root.workspace, - env: { ...fakeGatewayEnv(root, gateway), FX_MCP_ENGINE: "v2" }, + env: { ...fakeGatewayEnv(root, gateway) }, }); const initialized = await client.request("initialize", { protocolVersion: 1 }, 1) as any; - // Only what ACP v1 defines and MCP-v2 speaks: no HTTP+SSE, no "type": "acp". + // Only what ACP v1 defines and fx's MCP engine speaks: no HTTP+SSE, no "type": "acp". expect(initialized.result.agentCapabilities.mcpCapabilities).toEqual({ http: true, sse: false, acp: false }); const created = await client.request( "session/new", @@ -2887,7 +2887,7 @@ describe("acp: model-independent", () => { ) as any; expect(created.error).toBeUndefined(); await client.readLine(); - // MCP-v2 starts a server when the model first needs it. + // fx starts a server when the model first needs it. expect(httpFixture.requests).toHaveLength(0); await client.request("session/set_mode", { modeId: "auto" }, 3); const requestStart = gateway.requests.length; @@ -2954,7 +2954,7 @@ describe("acp: model-independent", () => { try { client = await AcpClient.create({ cwd: root.workspace, - env: { ...fakeGatewayEnv(root, gateway), FX_MCP_ENGINE: "v2" }, + env: { ...fakeGatewayEnv(root, gateway) }, // A short description limit, so the search truncates the tool's. args: ["--context-limit", "mcp_description_bytes=8", "acp"], }); @@ -3003,7 +3003,7 @@ describe("acp: model-independent", () => { try { client = await AcpClient.create({ cwd: root.workspace, - env: { ...fakeGatewayEnv(root, gateway), FX_MCP_ENGINE: "v2" }, + env: { ...fakeGatewayEnv(root, gateway) }, args: ["--context-limit", "mcp_description_bytes=8", "acp"], }); const init = await client.request("initialize", { protocolVersion: 1, clientCapabilities: { session: { notices: false } } }, 1) as any; @@ -3053,7 +3053,7 @@ describe("acp: model-independent", () => { try { client = await AcpClient.create({ cwd: root.workspace, - env: { ...fakeGatewayEnv(root, gateway), FX_MCP_ENGINE: "v2" }, + env: { ...fakeGatewayEnv(root, gateway) }, }); await client.request("initialize", { protocolVersion: 1 }, 1); const server = { type: "http", name: "fixture", url: httpFixture.url, headers: [] }; @@ -3101,14 +3101,14 @@ describe("acp: model-independent", () => { ); test( - "ACP on MCP-v2 answers session/load at once while the old session's stdio server stops", + "ACP answers session/load at once while the old session's stdio server stops", async () => { const root = createIsolatedRoot("fx-acp-mcp-v2-load-stop-"); const pidPath = join(root.root, "fixture.pid"); const eofPath = join(root.root, "fixture.eof"); const gateway = startFakeGateway([ fakeGatewayToolCall("call_stdio", MCP_TOOL_NAME, { text: "acp" }), - finalText("ACP MCP-v2 stdio"), + finalText("ACP MCP stdio"), ]); // It keeps running after its input closes, so fx sends SIGTERM after 1.5 s. let oldPid = 0; @@ -3125,7 +3125,7 @@ describe("acp: model-independent", () => { try { client = await AcpClient.create({ cwd: root.workspace, - env: { ...fakeGatewayEnv(root, gateway), FX_MCP_ENGINE: "v2" }, + env: { ...fakeGatewayEnv(root, gateway) }, }); await client.request("initialize", { protocolVersion: 1 }, 1); const created = await client.request("session/new", { cwd: root.workspace, mcpServers: [server] }, 2) as any; @@ -3163,15 +3163,15 @@ describe("acp: model-independent", () => { ); test( - "ACP on MCP-v2 starts a server again after session/load only once its old copy is gone", + "ACP starts a server again after session/load only once its old copy is gone", async () => { const root = createIsolatedRoot("fx-acp-mcp-v2-load-once-"); const pidPath = join(root.root, "fixture.pid"); const gateway = startFakeGateway([ fakeGatewayToolCall("call_old", MCP_TOOL_NAME, { text: "old" }), - finalText("ACP MCP-v2 old"), + finalText("ACP MCP old"), fakeGatewayToolCall("call_new", MCP_TOOL_NAME, { text: "new" }), - finalText("ACP MCP-v2 new"), + finalText("ACP MCP new"), ]); // It keeps running after its input closes, so the old copy takes 1.5 s to stop. const server = { @@ -3195,7 +3195,7 @@ describe("acp: model-independent", () => { try { client = await AcpClient.create({ cwd: root.workspace, - env: { ...fakeGatewayEnv(root, gateway), FX_MCP_ENGINE: "v2" }, + env: { ...fakeGatewayEnv(root, gateway) }, }); await client.request("initialize", { protocolVersion: 1 }, 1); const created = await client.request("session/new", { cwd: root.workspace, mcpServers: [server] }, 2) as any; @@ -3958,7 +3958,7 @@ describe("acp: model-independent", () => { ); test( - "ACP on MCP-v2 starts a project stdio server in its project", + "ACP starts a project stdio server in its project", async () => { const root = createIsolatedRoot("fx-acp-project-cwd-v2-"); const pidPath = join(root.root, "project-cwd.pid"); @@ -3982,7 +3982,7 @@ describe("acp: model-independent", () => { fakeGatewayToolCall("call_project", MCP_TOOL_NAME, { text: "acp" }), finalText("ACP project MCP complete"), ]); - const env = { ...fakeGatewayEnv(root, gateway), FX_MCP_ENGINE: "v2" }; + const env = { ...fakeGatewayEnv(root, gateway) }; try { const approved = await runFx(["mcp", "approve", "fixture"], { cwd: root.workspace, env }); expect(approved.code).toBe(0); @@ -5274,7 +5274,7 @@ describe("acp: model-independent", () => { ); test( - "ACP form elicitation answers an MCP-v2 server's input request", + "ACP form elicitation answers an MCP server's input request", async () => { const root = createIsolatedRoot("fx-acp-mcp-v2-form-"); const pidPath = join(root.root, "mcp-v2-form.pid"); @@ -5282,13 +5282,13 @@ describe("acp: model-independent", () => { const gateway = startFakeGateway([ fakeGatewayToolCall("select_form", "mcp_select_tool", { name: MCP_TOOL_NAME }), fakeGatewayToolCall("call_form", MCP_TOOL_NAME, { text: "acp-form" }), - finalText("ACP MCP-v2 form complete"), + finalText("ACP MCP form complete"), ]); const directRequests: Array> = []; try { client = await AcpClient.create({ cwd: root.workspace, - env: { ...fakeGatewayEnv(root, gateway), FX_MCP_ENGINE: "v2" }, + env: { ...fakeGatewayEnv(root, gateway) }, }); await client.request( "initialize", @@ -5596,7 +5596,7 @@ describe("acp: model-independent", () => { expect(calls[1]?.params?.inputResponses).toEqual({ confirm: { action: "accept" }, }); - // MCP-v2 sends the spec's opaque string back as it came. + // fx sends the spec's opaque string back as it came. expect(calls[1]?.params?.requestState).toEqual("opaque"); expect(calls[1]?.params?.name).toBe(calls[0]?.params?.name); expect(calls[1]?.params?.arguments).toEqual(calls[0]?.params?.arguments); diff --git a/tests/e2e/mcp-auth.test.ts b/tests/e2e/mcp-auth.test.ts index d06666630..eba00f76c 100644 --- a/tests/e2e/mcp-auth.test.ts +++ b/tests/e2e/mcp-auth.test.ts @@ -131,7 +131,7 @@ function startAuthFixture( /** The metadata's token endpoint, in place of its own. */ tokenEndpoint?: string; slackBridgeClientId?: string; - /** fx's Slack app signs in with `http://localhost:/callback`, as MCP-v2 does, not fx.sh's redirect. */ + /** fx's Slack app signs in with `http://localhost:/callback`, as fx does now, not fx.sh's redirect. */ slackLocalhostRedirect?: boolean; /** Answer every refresh with this status and body, such as Slack's HTTP-200 `ok:false`. */ refreshAnswer?: { status: number; body: unknown }; @@ -547,7 +547,7 @@ function seedExpiredCredentials( } /** - * Holds MCP-v2's store lock from another process, as a busy fx would, for + * Holds fx's MCP credential store lock from another process, as a busy fx would, for * `seconds`; resolves once it is held. */ async function holdStoreLock(root: ReturnType, seconds: number) { @@ -559,7 +559,7 @@ async function holdStoreLock(root: ReturnType, seconds: numbe return holder; } -/** A login in MCP-v2's own store (FX_DISABLE_KEYCHAIN), as fx saves it. */ +/** A login in fx's MCP credential store (FX_DISABLE_KEYCHAIN), as fx saves it. */ function seedV2Credential(root: ReturnType, activeAuth: AuthFixture, fields: Record = {}) { const origin = new URL(activeAuth.url).origin; const directory = join(root.home, ".fx", "mcp-credentials"); @@ -1322,9 +1322,9 @@ describe("MCP login", () => { expect(auth.tokenExchanges).toBe(0); }, 60_000); - // A v1 login taken over would be refreshed by v1 and MCP-v2 apart, - // neither seeing the other's saves, so a server that takes each refresh - // token once signs both out. MCP-v2 signs in on its own instead. + // A v1 login taken over would be refreshed by an older fx and this one + // apart, neither seeing the other's saves, so a server that takes each + // refresh token once signs both out. fx signs in on its own instead. test("a v1 login isn't carried over: the server needs its own login, and v1's store stays as it is", async () => { upstream = startModernMcpHttpFixture("json"); auth = startAuthFixture(upstream.url); @@ -1405,7 +1405,7 @@ describe("MCP login", () => { expect(opened.pathname).toBe("/authorize"); expect(opened.searchParams.get("client_id")).toBe(FX_SLACK_CLIENT_ID); expect(opened.searchParams.get("redirect_uri")).toBe(`http://localhost:${port}/callback`); - // MCP-v2 adds offline_access when the authorization server lists it, as + // fx adds offline_access when the authorization server lists it, as // the stand-in does; Slack doesn't list it. expect(opened.searchParams.get("scope")!.split(" ").filter((x) => x !== "offline_access").sort()).toEqual([ "channels:history", "channels:read", "channels:write", "chat:write", "emoji:read", "files:write", diff --git a/tests/e2e/mcp-http.test.ts b/tests/e2e/mcp-http.test.ts index 26edd905d..165e1dbe9 100644 --- a/tests/e2e/mcp-http.test.ts +++ b/tests/e2e/mcp-http.test.ts @@ -1149,7 +1149,7 @@ describe("modern MCP Streamable HTTP", () => { expect(fixture.cancelledCalls).toBe(1); }, 30_000); - test("MCP-v2 closes a held-open SSE response once its answer arrives", async () => { + test("fx closes a held-open SSE response once its answer arrives", async () => { const heldOpen = startModernMcpHttpFixture("held_open_final"); fixture = heldOpen; const root = createRoot("held-open-final-v2", heldOpen, 1_000); @@ -1172,7 +1172,7 @@ describe("modern MCP Streamable HTTP", () => { ["ask", "--json", "--auto", "--no-save", "Call the held-open HTTP fixture."], { cwd: root.workspace, - env: { ...fixtureEnv(root, gateway), FX_MCP_ENGINE: "v2" }, + env: { ...fixtureEnv(root, gateway) }, timeoutMs: 20_000, }, ); diff --git a/tests/e2e/mcp-legacy-remote.test.ts b/tests/e2e/mcp-legacy-remote.test.ts index c2ad3fd63..68be78349 100644 --- a/tests/e2e/mcp-legacy-remote.test.ts +++ b/tests/e2e/mcp-legacy-remote.test.ts @@ -789,7 +789,7 @@ describe("version-scoped legacy MCP remote transports", () => { } test.skipIf(!tmuxAvailable())( - "MCP-v2: /mcp remove ends a 2025 session with a DELETE, and fx keeps running", + "/mcp remove ends a 2025 session with a DELETE, and fx keeps running", async () => { // The removal reloads fx's MCP host, which closes the old host's // servers; a 2025 session waits there for its DELETE. @@ -802,7 +802,7 @@ describe("version-scoped legacy MCP remote transports", () => { cwd: root.workspace, width: 100, height: 30, - env: { ...fixtureEnv(root, gateway), FX_MCP_ENGINE: "v2" }, + env: { ...fixtureEnv(root, gateway) }, }); await tui.waitForComposer(15_000); @@ -841,9 +841,9 @@ describe("version-scoped legacy MCP remote transports", () => { 40_000, ); - // MCP-v2's timeouts are engine-wide, so this waits out the 60 s default. + // fx's MCP timeouts are engine-wide, so this waits out the 60 s default. test( - "MCP-v2: a 2025 server that never answers initialize fails after its timeout instead of hanging", + "a 2025 server that never answers initialize fails after its timeout instead of hanging", async () => { const fixture = startLegacyStreamableHttpFixture("2025-11-25", { mode: "hold_initialize" }); streamable = fixture; @@ -851,7 +851,7 @@ describe("version-scoped legacy MCP remote transports", () => { const started = Date.now(); const result = await runFx(["mcp", "show", "fixture", "--json"], { cwd: root.workspace, - env: { HOME: root.home, FX_AUTO_UPGRADE: "0", FX_MCP_ENGINE: "v2" }, + env: { HOME: root.home, FX_AUTO_UPGRADE: "0" }, timeoutMs: 90_000, }); expect(result.signal).toBeNull(); @@ -864,7 +864,7 @@ describe("version-scoped legacy MCP remote transports", () => { ); test( - "MCP-v2: a 2025 server that never answers notifications/initialized fails with the reason instead of looking ready with no tools", + "a 2025 server that never answers notifications/initialized fails with the reason instead of looking ready with no tools", async () => { const fixture = startLegacyStreamableHttpFixture("2025-11-25", { mode: "hold_initialized" }); streamable = fixture; @@ -872,7 +872,7 @@ describe("version-scoped legacy MCP remote transports", () => { const started = Date.now(); const result = await runFx(["mcp", "show", "fixture", "--json"], { cwd: root.workspace, - env: { HOME: root.home, FX_AUTO_UPGRADE: "0", FX_MCP_ENGINE: "v2" }, + env: { HOME: root.home, FX_AUTO_UPGRADE: "0" }, timeoutMs: 90_000, }); expect(result.signal).toBeNull(); diff --git a/tests/e2e/mcp-verbs.test.ts b/tests/e2e/mcp-verbs.test.ts index c24a81c73..b6882cd54 100644 --- a/tests/e2e/mcp-verbs.test.ts +++ b/tests/e2e/mcp-verbs.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { runFx } from "../evals/eval-helpers"; import { fakeGatewayFinalText, startFakeGateway, TmuxSession, tmuxAvailable } from "./tmux-helpers"; -// `fx mcp` on MCP-v2. Sign-in is covered in mcp-auth.test.ts. +// `fx mcp`. Sign-in is covered in mcp-auth.test.ts. const MODERN_FIXTURE = join(import.meta.dirname, "fixtures", "mcp-modern-stdio.mjs"); @@ -48,7 +48,6 @@ function createRoot(profile?: unknown, project?: unknown) { projectPath: join(workspace, ".mcp.json"), env: { HOME: home, - FX_MCP_ENGINE: "v2", FX_DISABLE_KEYCHAIN: "1", FX_AUTO_UPGRADE: "0", AI_GATEWAY_API_KEY: "fake-mcp-verbs-key", @@ -70,7 +69,7 @@ const fixtureServer = (extra: Record = {}) => ({ ...extra, }); -describe("fx mcp on MCP-v2", () => { +describe("fx mcp", () => { test("add writes each file and keeps every key it doesn't know", async () => { // Raw text, so the number's spelling reaches fx as written. const root = createRoot('{"theme":"dark","mcp":{"old":{"command":"true","environment":{"A":"1"},"startup_timeout_ms":1.50}}}'); @@ -395,7 +394,7 @@ describe("fx mcp on MCP-v2", () => { }, 30_000); }); -describe("/mcp in the shell on MCP-v2", () => { +describe("/mcp in the shell", () => { test.skipIf(!tmuxAvailable())("/mcp runs the same verbs and prints the same text", async () => { // The shell can ask questions, so it says so to servers. const root = createRoot({ mcpServers: { fixture: fixtureServer({ env: { FX_MCP_EXPECT_ELICITATION: "both" } }) } }); @@ -710,7 +709,7 @@ describe("/mcp in the shell on MCP-v2", () => { }, 90_000); }); -describe("fx ask on MCP-v2", () => { +describe("fx ask", () => { test("fx ask names unapproved project servers and the command that approves them", async () => { const root = createRoot(undefined, { mcpServers: { proj: fixtureServer() } }); gateway = startFakeGateway([fakeGatewayFinalText("done")], { models: [{ id: "openai/gpt-5", type: "language", tags: ["tool-use"] }] }); diff --git a/tests/tla/README.md b/tests/tla/README.md index 1c525c304..a63e1fc50 100644 --- a/tests/tla/README.md +++ b/tests/tla/README.md @@ -8,7 +8,7 @@ TLA+ specifications for fx, checked with TLC, together with the scripts and Bun | `cli/` | How `fx` and `fx ask` handle `--model`, `--fast`, `--ultrafast`, and `--effort` at launch, for the current design and a proposed one that validates them. | `cli/check.sh`, `bun test drivers/cli/` | | `durable/` | Durable libfx sessions (`sdk/fx-sdk.js`): the journal, tool replay, parallel batches, fencing, the inbox, resumption, and snapshots. | `durable/check.sh`, `durable/mutants.py` | | `indexer/` | When a scanned root's listing may be reused, merging parallel grep results, the file picker's loading row, and listing snapshots (`src/core/indexer/`). | `indexer/check.sh` | -| `mcp/` | The MCP-v2 engine (`src/mcp/`): wire format, requests, stdio, version detection, the tool catalog, both HTTP transports, input-required rounds, authorization, subscriptions, elicitation, and the engine. `rules.md` lists the MCP specification rules the models cite by ID. | `mcp/check.sh`, then the drivers in `drivers/mcp/` | +| `mcp/` | The MCP engine (`src/mcp/`): wire format, requests, stdio, version detection, the tool catalog, both HTTP transports, input-required rounds, authorization, subscriptions, elicitation, and the engine. `rules.md` lists the MCP specification rules the models cite by ID. | `mcp/check.sh`, then the drivers in `drivers/mcp/` | | `mcp_host/` | fx's MCP host (`src/core/mcp_host/`): host lifecycle, saved credentials, logins shared between processes, sign-in, elicitation routing, and calls held for the URLs a server asks the user to open. | `mcp_host/check.sh`, `bun test drivers/mcp_host/` | | `sdk/` | libfx durable sessions (`DurableSession.tla`), agent names and routing (`AgentRouting.tla`), tenants whose agents share one process (`TenantIsolation.tla`), steering (`DurableSteer.tla`), and the provider credential edge (`ProviderAuth.tla`). `check.sh` also writes the TLC state graphs the drivers walk. | `sdk/check.sh`, then the drivers | | `session_manager/` | The session log, turn lifecycle, writer lock, snapshots, fork, subagents, catalog, host wiring, the side-file move, and v1 conversion. `rollout/` holds the models of the changes that moved fx onto the session manager. `system-design.md` is the design record the specs check and defines the `D1` to `D62` decision numbers that code comments cite. | `session_manager/check.sh`, `session_manager/rollout/run.py`, `bun test drivers/session_manager/` | @@ -49,11 +49,11 @@ npm ci --prefix sdk/durable ## MCP in CI -CI's `TLA+ MCP models and drivers` job runs each MCP-v2 model's smoke config (`mcp/run.py check`), `mcp_host/check.sh`, and both MCP drivers. The MCP-v2 models' planted bugs (`mcp/check.sh`) run only by hand: they measure the checks, not the code. +CI's `TLA+ MCP models and drivers` job runs each engine model's smoke config (`mcp/run.py check`), `mcp_host/check.sh`, and both MCP drivers. The engine models' planted bugs (`mcp/check.sh`) run only by hand: they measure the checks, not the code. ## MCP models without a driver here -The MCP-v2 models of the two HTTP transports, input-required rounds, authorization, subscriptions, and elicitation are model-checked only. Their traces came from a conformance adapter that is not part of fx. The `drivers/mcp_host/` sign-in tests drive real fx over HTTP against the fake server in `drivers/mcp/fakes/`. +The engine models of the two HTTP transports, input-required rounds, authorization, subscriptions, and elicitation are model-checked only. Their traces came from a conformance adapter that is not part of fx. The `drivers/mcp_host/` sign-in tests drive real fx over HTTP against the fake server in `drivers/mcp/fakes/`. The `cli/` and `shell_command/` tests also check a proposed design. Those checks run only with `FX_TLA_PROPOSED=1`, and they fail until fx implements the design. @@ -69,9 +69,9 @@ tests/tla/session_manager/trace/selftest.sh # the trace validator mu tests/tla/session_manager/rollout/run.py check # the rollout models; mutate for their planted bugs tests/tla/indexer/check.sh # every config and its expected verdict tests/tla/acp/check.sh # likewise for acp/, cli/, durable/, shell_command/, terminal/, usage/ -tests/tla/mcp/check.sh # every MCP-v2 model and planted bug +tests/tla/mcp/check.sh # every MCP engine model and planted bug tests/tla/mcp_host/check.sh # every MCP host model, planted bug, and finding -bun tests/tla/drivers/mcp/driver.ts # src/mcp/ along the MCP-v2 models +bun tests/tla/drivers/mcp/driver.ts # src/mcp/ along the MCP engine models bun test ./tests/tla/drivers/mcp_host/ # fx's MCP host along its models; FX_BIN picks the binary tests/tla/sdk/check.sh # the SDK specs; writes the state graphs the drivers walk bun tests/tla/drivers/drive.mjs send --walks 40 # one durable-session graph; see .github/workflows/ci.yml for the rest diff --git a/tests/tla/drivers/mcp/bridge.ts b/tests/tla/drivers/mcp/bridge.ts index c47b01431..f7355a921 100644 --- a/tests/tla/drivers/mcp/bridge.ts +++ b/tests/tla/drivers/mcp/bridge.ts @@ -1,4 +1,4 @@ -// Trace bridge for the MCP-v2 engine models in tests/tla/mcp/. +// Trace bridge for the MCP engine models in tests/tla/mcp/. // // Turns a JSONL step trace into a generated TLA+ module (TraceObs) and asks TLC // whether it is a behavior of the machine's model, using a hand-written trace diff --git a/tests/tla/drivers/mcp/driver.ts b/tests/tla/drivers/mcp/driver.ts index f8ab875f3..b07855d59 100644 --- a/tests/tla/drivers/mcp/driver.ts +++ b/tests/tla/drivers/mcp/driver.ts @@ -1,4 +1,4 @@ -// Bun driver for the MCP-v2 engine models in tests/tla/mcp/. +// Bun driver for the MCP engine models in tests/tla/mcp/. // // Builds the binaries the selected scenarios need, runs each scenario with an // isolated HOME and MCPV2_TRACE, checks every expected trace through the diff --git a/tests/tla/drivers/mcp/scenario.ts b/tests/tla/drivers/mcp/scenario.ts index fd255bb92..ca38d17de 100644 --- a/tests/tla/drivers/mcp/scenario.ts +++ b/tests/tla/drivers/mcp/scenario.ts @@ -1,4 +1,4 @@ -// Scenario contract for the MCP-v2 Bun driver. +// Scenario contract for the MCP engine Bun driver. import type { Verdict } from "./bridge"; diff --git a/tests/tla/drivers/mcp_host/credentials.test.ts b/tests/tla/drivers/mcp_host/credentials.test.ts index 9a8aa6c69..39a97b57c 100644 --- a/tests/tla/drivers/mcp_host/credentials.test.ts +++ b/tests/tla/drivers/mcp_host/credentials.test.ts @@ -52,7 +52,7 @@ class Session { const CREDENTIAL = JSON.stringify({ access: "kept-by-newer-fx", refresh: "r", expires_at_ms: null, token_endpoint: "http://127.0.0.1:9/token", revocation_endpoint: null, resource: URL, client_id: "c", client_secret: null, method: "none" }); -/** MCP-v2's store holding web's login, as `version` (1 is this fx's). */ +/** fx's MCP credential store holding web's login, as `version` (1 is this fx's). */ function writeV2(f: Fixture, version = 1) { mkdirSync(join(f.home, ".fx", "mcp-credentials"), { recursive: true, mode: 0o700 }); writeFileSync(v2Path(f), JSON.stringify({ version, servers: [{ name: "web", url: URL, credential: CREDENTIAL }] }), { mode: 0o600 }); @@ -130,7 +130,7 @@ test("a logout stays when the store becomes unreadable (trace ACCEPTED)", async expect(verdict.verdict).toBe("ACCEPTED"); }, 120_000); -test("With MCP-v2's store deleted after a logout, v1's login still isn't taken (trace ACCEPTED)", async () => { +test("With fx's MCP credential store deleted after a logout, v1's login still isn't taken (trace ACCEPTED)", async () => { const s = new Session(setup("cred-deleted")); writeV1(s.f, [v1Entry("web", URL)]); writeV2(s.f); diff --git a/tests/tla/drivers/mcp_host/driver.ts b/tests/tla/drivers/mcp_host/driver.ts index 844203dde..fd1834ae1 100644 --- a/tests/tla/drivers/mcp_host/driver.ts +++ b/tests/tla/drivers/mcp_host/driver.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import type { TraceRecord } from "./trace"; export const FIXTURE = join(import.meta.dirname, "fixtures", "stdio-server.ts"); -/** MCP-v2's fake Streamable HTTP server with fake OAuth, used read-only. */ +/** The engine models' fake Streamable HTTP server with fake OAuth, used read-only. */ export const HTTP_FAKE = join(import.meta.dirname, "../mcp/fakes/http.ts"); export const BUN = process.execPath; @@ -22,13 +22,12 @@ export function fixture(root: string, name: string): Fixture { return { base, home, workspace, control, serverLog: join(base, "servers.jsonl"), traceLog: join(base, "fx-trace.log") }; } -/** fx's environment: scratch HOME, MCP-v2, no Keychain, no browser, no sound, a fake gateway key. */ +/** fx's environment: scratch HOME, no Keychain, no browser, no sound, a fake gateway key. */ export function fxEnv(f: Fixture, extra: Record = {}): Record { return { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: f.home, TMPDIR: process.env.TMPDIR ?? "/tmp", - FX_MCP_ENGINE: "v2", FX_DISABLE_KEYCHAIN: "1", FX_NO_OPEN_BROWSER: "1", FX_AUTO_UPGRADE: "0", diff --git a/tests/tla/drivers/mcp_host/model.ts b/tests/tla/drivers/mcp_host/model.ts index 71a7d57cb..2bee947ce 100644 --- a/tests/tla/drivers/mcp_host/model.ts +++ b/tests/tla/drivers/mcp_host/model.ts @@ -1,8 +1,8 @@ -// Model gate for the fx-layer MCP-v2 models: each model's smoke config +// Model gate for the fx-layer MCP models: each model's smoke config // (safety and liveness), its stress config, every planted bug, the stated // properties the code is expected to break (findings), and repair witnesses. // -// Verdicts, as MCP-v2's tools/run.py gives them: +// Verdicts, as tests/tla/mcp/run.py gives them: // PASS exit 0, the completed marker, an empty queue // CAUGHT a planted bug: exit 12 naming its invariant, or exit 13 for its property // FINDING a findings config: exit 12 naming the stated invariant (13 for a property) diff --git a/tests/tla/drivers/mcp_host/sign-in.test.ts b/tests/tla/drivers/mcp_host/sign-in.test.ts index c0ae76cf0..c4691f806 100644 --- a/tests/tla/drivers/mcp_host/sign-in.test.ts +++ b/tests/tla/drivers/mcp_host/sign-in.test.ts @@ -1,4 +1,4 @@ -// SignIn: real sign-ins against MCP-v2's fake Streamable HTTP server with +// SignIn: real sign-ins against the engine models' fake Streamable HTTP server with // fake OAuth (../mcp/fakes/http.ts): discovery, // registration, an authorization URL the harness follows itself (no browser), // the loopback callback, and the code redemption the fake logs. Checked @@ -18,7 +18,7 @@ const RULES = [ ]; async function startFake(f: Fixture, server: Record = {}) { - if (!existsSync(HTTP_FAKE)) throw new Error(`harness: MCP-v2's fake HTTP server is missing at ${HTTP_FAKE}`); + if (!existsSync(HTTP_FAKE)) throw new Error(`harness: the fake HTTP server is missing at ${HTTP_FAKE}`); const rules = join(f.base, "rules.json"), auth = join(f.base, "auth.json"), log = join(f.base, "http-fake.jsonl"); writeFileSync(rules, JSON.stringify(RULES)); writeFileSync(auth, JSON.stringify({ servers: [server], challengeScope: "mcp:read" })); diff --git a/tests/tla/drivers/mcp_host/trace.ts b/tests/tla/drivers/mcp_host/trace.ts index e2cb385cb..96f1c8277 100644 --- a/tests/tla/drivers/mcp_host/trace.ts +++ b/tests/tla/drivers/mcp_host/trace.ts @@ -1,5 +1,5 @@ // The trace bridge: turns what a scenario saw of real fx into TraceObs.tla -// and checks it against a model's trace wrapper, MCP-v2's way (sentinel +// and checks it against a model's trace wrapper, as drivers/mcp/bridge.ts does (sentinel // invariant: violated exactly when a complete match exists). // // Verdicts: diff --git a/tests/tla/mcp/check.sh b/tests/tla/mcp/check.sh index 7b385c698..a89ee9b91 100755 --- a/tests/tla/mcp/check.sh +++ b/tests/tla/mcp/check.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Model-check the MCP-v2 engine models (src/mcp/) with TLC. +# Model-check the MCP engine models (src/mcp/) with TLC. # # run.py reads catalog.json: each model's smoke config must pass, and each # planted bug in .mutants.json must be caught by its named property. diff --git a/tests/tla/mcp/rules.md b/tests/tla/mcp/rules.md index 415b34b40..8416e2797 100644 --- a/tests/tla/mcp/rules.md +++ b/tests/tla/mcp/rules.md @@ -1,6 +1,6 @@ -# MCP-v2 client rules +# MCP client rules -Every client-side rule from the MCP specification that the MCP-v2 engine +Every client-side rule from the MCP specification that the MCP engine (`src/mcp/`) follows, with the ID that the models in this folder and the drivers in `../drivers/mcp/` cite. diff --git a/tests/tla/mcp/run.py b/tests/tla/mcp/run.py index 233caf1b1..c2e9f6e0f 100755 --- a/tests/tla/mcp/run.py +++ b/tests/tla/mcp/run.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Run TLC checks for the MCP-v2 engine models in this folder. +"""Run TLC checks for the MCP engine models in this folder. Modes: list show the models in spec/catalog.json diff --git a/tests/tla/mcp_host/Credentials.tla b/tests/tla/mcp_host/Credentials.tla index 80076d58e..db8e0b948 100644 --- a/tests/tla/mcp_host/Credentials.tla +++ b/tests/tla/mcp_host/Credentials.tla @@ -1,5 +1,5 @@ ---- MODULE Credentials ---- -\* fx's MCP-v2 credential store (src/core/mcp_host/credentials.zig), file +\* fx's MCP credential store (src/core/mcp_host/credentials.zig), file \* backend (FX_DISABLE_KEYCHAIN=1; the Keychain backend reads and writes the \* same document through native_keychain). \* @@ -9,9 +9,9 @@ \* is atomic here, and reads the document first (Store.read): a missing file \* is empty; one that doesn't parse, or whose version isn't 1, is unreadable, \* and nothing writes over it: get finds no login, and put and remove -\* fail. A read writes nothing. v1's logins (mcp_auth_store.zig) are never -\* taken over: v1 and MCP-v2 refreshing one login apart, neither seeing -\* the other's saves, would sign it out. +\* fail. A read writes nothing. v1's logins (`credentials.json`) are never +\* taken over: an older fx and this one refreshing one login apart, neither +\* seeing the other's saves, would sign it out. \* Operations: get (config.resolve -> Runtime.savedCredential, for every \* HTTP server, whenever a host starts: `fx mcp list`, `fx ask`, the shell), \* put (a `credential` event after a sign-in or refresh), remove (logout: @@ -33,7 +33,7 @@ Kinds == {"absent", "ok", "unreadable"} VARIABLES kind, \* the document: missing, readable, or unreadable entries, \* what it holds (an unreadable one still holds it, for a reader that can read it) - v1, \* v1's logins (mcp_auth_store.zig), never changed by MCP-v2 + v1, \* v1's logins (`credentials.json`), never changed by this fx loggedOut, \* ghost: names signed out of and not signed in to since kept, \* ghost: names signed in to and not signed out of since taken, \* ghost: a write added a v1 login no sign-in here made @@ -115,7 +115,7 @@ Delete == /\ kept' = {} /\ UNCHANGED <> -\* Environment: a sign-in with the v1 engine (FX_MCP_ENGINE unset). +\* Environment: a sign-in by an older fx, which saved it in v1's store. V1Login(n) == /\ "v1login" \in Faults /\ n \notin v1 /\ v1' = v1 \cup {n} @@ -132,7 +132,7 @@ Spec == Init /\ [][Next]_vars \* ---- Safety ---- -\* MCP-v2 never takes a v1 login; each server signs in on its own. +\* fx never takes a v1 login; each server signs in on its own. NoV1LoginTaken == ~taken \* What was signed in to, and not signed out of since, is kept. diff --git a/tests/tla/mcp_host/HostLifecycle.tla b/tests/tla/mcp_host/HostLifecycle.tla index a4be506e3..c9d1b1b1c 100644 --- a/tests/tla/mcp_host/HostLifecycle.tla +++ b/tests/tla/mcp_host/HostLifecycle.tla @@ -1,5 +1,5 @@ ---- MODULE HostLifecycle ---- -\* fx's MCP-v2 host lifecycle, as the shell runs it. +\* fx's MCP host lifecycle, as the shell runs it. \* \* Code (vercel-labs/fx at 53a561bea): \* - src/core/app/app_mcp_runtime.zig: loadHost, reloadHost (replaceHost diff --git a/tests/tla/mcp_host/SignIn.tla b/tests/tla/mcp_host/SignIn.tla index f4b8f0e5a..d5ca191a9 100644 --- a/tests/tla/mcp_host/SignIn.tla +++ b/tests/tla/mcp_host/SignIn.tla @@ -1,5 +1,5 @@ ---- MODULE SignIn ---- -\* fx's MCP-v2 sign-in for one HTTP server (vercel-labs/fx at 53a561bea). +\* fx's MCP sign-in for one HTTP server (vercel-labs/fx at 53a561bea). \* \* Code: \* - src/core/mcp_host/runtime.zig: startLogin (endLogin of this server's @@ -9,7 +9,7 @@ \* the bridge's origin, carrying the state `.authorize` gave the \* listener, is posted as finish_login; the task then ends), \* finishLogin (`fx mcp login NAME --no-browser`: a pasted address, -\* past the listener), `.finish_login` (engine.finishSignIn: MCP-v2's +\* past the listener), `.finish_login` (engine.finishSignIn: the engine's \* flow checks the state), \* onServer `.authorize` / `.signed_in` / `.sign_in_failed`, expireLogins \* (5 minutes), shutdown (endLogin for every login, each with a @@ -130,7 +130,7 @@ Arrive(k, s, kind) == ELSE UNCHANGED <> /\ UNCHANGED <> -\* finishLogin: the address the browser ended on, pasted. It goes to MCP-v2 +\* finishLogin: the address the browser ended on, pasted. It goes to the engine \* as it is, past the listener, so a wrong one fails the sign-in there. Paste(k, s) == /\ phase[k] = "open" @@ -138,7 +138,7 @@ Paste(k, s) == /\ got' = [got EXCEPT ![k] = s] /\ UNCHANGED <> -\* `.finish_login`: MCP-v2 checks the state against the live flow. +\* `.finish_login`: the engine checks the state against the live flow. Handle(k) == /\ phase[k] = "consumed" /\ alive[hostOf[k]] /\ live[hostOf[k]] = k /\ LET h == hostOf[k]