mirror of
https://github.com/vercel-labs/fx.git
synced 2026-10-28 10:15:17 +08:00
Describe MCP as fx's one engine, and drop what the switch left
With the old client gone, comments, docs, test names and help no longer name a second MCP engine or the FX_MCP_ENGINE switch, and say what each folder holds now. The always-true selected() switch is gone. fx mcp's help comes from its own verbs, so the command spec drops the old command list, and fx slack's help names fx mcp login. health.zig keeps only the config inspection and classify; its runtime renderers had no caller.
This commit is contained in:
45 files changed
+123
-796
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -146,23 +146,8 @@ pub const top_level_specs = [_]TopLevelSpec{
|
||||
.kind = .mcp,
|
||||
.token = "mcp",
|
||||
.usage = "mcp <command> ...",
|
||||
// `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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
+5
-645
@@ -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 <name> <command> [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 <name> --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 <name> <command> [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,
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") } };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
|
||||
+1
-5
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+20
-20
@@ -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<Record<string, any>> = [];
|
||||
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);
|
||||
|
||||
@@ -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:<port>/callback`, as MCP-v2 does, not fx.sh's redirect. */
|
||||
/** fx's Slack app signs in with `http://localhost:<port>/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<typeof createRoot>, seconds: number) {
|
||||
@@ -559,7 +559,7 @@ async function holdStoreLock(root: ReturnType<typeof createRoot>, 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<typeof createRoot>, activeAuth: AuthFixture, fields: Record<string, unknown> = {}) {
|
||||
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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
...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"] }] });
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string> = {}): Record<string, string> {
|
||||
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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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" }));
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 <Module>.mutants.json must be caught by its named property.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <<v1, loggedOut, taken, overwritten>>
|
||||
|
||||
\* 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <<phase, got, foreignTaken>>
|
||||
/\ UNCHANGED <<cur, alive, retired, leases, hostOf, live, flow, shownUrl, indicator, saved, next, badRedeem>>
|
||||
|
||||
\* 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 <<cur, alive, retired, leases, hostOf, live, flow, shownUrl, indicator, saved, next, badRedeem, foreignTaken>>
|
||||
|
||||
\* `.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]
|
||||
|
||||
Reference in new issue
Block a user