diff --git a/CHANGELOG.md b/CHANGELOG.md index e9205b5f..885b601e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. The format - [#18](https://github.com/mohanagy/miftah/issues/18) The packed-package contract now exercises the installed CLI through shell and Windows command quoting, paths with spaces, generated help, stable category exits, JSON automation output, and normalized/redacted audit-log output. - [#18](https://github.com/mohanagy/miftah/issues/18) The CLI reference now documents generated help, every command and option, version compatibility output, exit statuses, JSON streams, and audit snapshot/follow safety boundaries. - [#19](https://github.com/mohanagy/miftah/issues/19) The versioned strict preset catalog, first-run onboarding wizard, generated absolute client snippets, compatibility matrix, and exact generated examples are documented and contract-tested. Tests validate generated configuration without constructing or starting external providers. +- [#20](https://github.com/mohanagy/miftah/issues/20) Metadata-only routing context now combines bounded workspace signals with deterministic profile selection, capability-gated MCP roots, strict project markers, and sanitized route-preview/audit evidence. ### Changed diff --git a/README.md b/README.md index 1d3ed8ff..efa1f586 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ Routing can use the active profile or rules matching tool arguments: When several profiles match, Miftah refuses to guess. Use explicit profile switching for write and destructive actions. The same routing, policy, redaction, and audit pipeline applies to upstream tool calls, resource reads, and prompt retrieval. Policy patterns use each upstream tool's original name for tools, `resources/read` for reads, and `prompts/get` for prompt retrieval. A deny, confirmation-required, blocked, or ambiguous decision is returned before Miftah forwards the read or prompt request. Provider token scopes still matter: local policy cannot make a write-capable provider token read-only. Profiles that set a policy name must reference an existing entry in `policies`, while profiles with no `policy` field keep the default allow behavior. +Miftah can also match bounded workspace metadata without treating a project file as configuration. It resolves a valid environment hint, then the nearest valid project-marker hint, then matching rules over tool arguments and collected context, then the configured fallback. Rules that select different profiles return `ROUTING_AMBIGUOUS`, and a context hint never authorizes a destructive operation that requires an explicit rule. `miftah_route_preview` and eligible audit records expose only sanitized routing evidence, not raw project environment values or project file contents. See [routing context](docs/config.md#routing-context) for the marker schema, root behavior, and evidence boundary. + ## Secret handling Supported local references include environment variables (`${NAME}` and `secretref:env://NAME`), configured dotenv files (`secretref:dotenv://NAME`), and explicitly opt-in plaintext (`secretref:plain://...`). OS keychains and external secret CLIs are reserved extension points. Secrets are redacted from diagnostics, errors, stderr forwarding, audit entries, and tool responses. @@ -111,7 +113,7 @@ Use `miftah doctor` to inspect config and upstream readiness without printing pr ## Audit logging -Set `audit.path` to record one terminal JSONL event for every supported MCP operation, including discovery, management, tool, resource, and prompt requests. Events include a per-process session ID, request/event ID, source and selected profiles, upstream, routing and policy metadata where applicable, terminal outcome, stable error code, and duration. Wrapper and upstream lifecycle transitions are recorded separately. Arguments are omitted unless `audit.includeArguments` is `true`. +Set `audit.path` to record one terminal JSONL event for every supported MCP operation, including discovery, management, tool, resource, and prompt requests. Events include a per-process session ID, request/event ID, source and selected profiles, upstream, routing and policy metadata where applicable, terminal outcome, stable error code, and duration. Route previews and proxied operations additionally include sanitized `routingEvidence` from their collector snapshot. Wrapper and upstream lifecycle transitions are recorded separately. Arguments are omitted unless `audit.includeArguments` is `true`. New audit directories and files use owner-only permissions where the platform supports them. `audit.failureMode` defaults to `"fail-closed"`, which verifies the audit sink before dispatch and refuses the request if it cannot be prepared. A terminal write can still fail after an upstream side effect completes, so treat a post-dispatch `AUDIT_WRITE_FAILED` as an indeterminate outcome and do not blindly retry non-idempotent tools. Set it to `"fail-open"` only when availability outweighs that guarantee; the original operation remains available and `miftah_health` reports a redacted `AUDIT_WRITE_FAILED` audit-health entry. diff --git a/docs/architecture.md b/docs/architecture.md index 073deb17..6224b28f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,7 +18,9 @@ Per-profile start and restart single-flight state, attempt tokens, and lifecycle The server advertises management tools plus tools discovered from the active profile. It advertises `tools.listChanged` and emits `notifications/tools/list_changed` after profile changes, restarts, and recovery that changes the public tool snapshot. Unknown names are rejected from the capability snapshot rather than guessed or forwarded. If a routing rule targets another profile, Miftah forwards only when that profile exposes the same name with an identical client-visible schema; otherwise it returns `TOOL_SCHEMA_MISMATCH`. In strict discovery mode, Miftah preflights every configured profile and rejects unavailable upstreams or any mismatched exposed tool contract before publishing a snapshot. -Every supported MCP request enters one outer audit scope. The scope records one terminal operation event on success or safe failure and also covers discovery/list failures, unknown names, and management tools that do not enter the proxy pipeline. `OperationPipeline` enriches proxied tool calls, resource reads, and prompt retrieval with captured source/target profile, upstream, routing, policy, and risk metadata; it does not emit its own record. It captures the source profile state before awaiting work, resolves routing against that fixed active-profile fallback, evaluates the selected profile policy, resolves the exact target upstream route, executes, and redacts the result or error. Tools retain their original upstream names for routing and policy compatibility; resource reads and prompt retrieval use the stable policy names `resources/read` and `prompts/get`. Denied, confirmation-required, blocked, and ambiguous operations never resolve or execute an upstream read/get route. Upstream managers publish typed lifecycle transitions, which the server records as separate audit events without letting audit I/O interrupt cleanup or recovery. +The metadata-only routing context collector is the sole source of workspace context, profile hints, preview evidence, and proxied-operation audit evidence. It reads only allowlisted bounded metadata: file roots, cwd, two environment selectors, strict project markers, package/workspace fields, and a local Git origin. The runtime configuration file remains separate from that input. Once initialized, `MiftahServer` capability-gates `roots/list`, caches only root URIs per client connection, and refreshes only on an advertised roots-list-changed notification. Every proxied operation and route preview receives one immutable snapshot; it uses that snapshot for rule matching, hint selection, and redacted evidence so routing cannot change between those steps. + +Every supported MCP request enters one outer audit scope. The scope records one terminal operation event on success or safe failure and also covers discovery/list failures, unknown names, and management tools that do not enter the proxy pipeline. `OperationPipeline` enriches proxied tool calls, resource reads, and prompt retrieval with captured source/target profile, upstream, routing, policy, risk, and sanitized `routingEvidence`; it does not emit its own record. It captures the source profile state before awaiting work, resolves routing against that fixed active-profile fallback, evaluates the selected profile policy, resolves the exact target upstream route, executes, and redacts the result or error. Route preview uses the same captured fallback and collector contract. Tools retain their original upstream names for routing and policy compatibility; resource reads and prompt retrieval use the stable policy names `resources/read` and `prompts/get`. Denied, confirmation-required, blocked, and ambiguous operations never resolve or execute an upstream read/get route. Upstream managers publish typed lifecycle transitions, which the server records as separate audit events without letting audit I/O interrupt cleanup or recovery. For a multi-entry `upstreams` map, `ResourcePromptRegistry` discovers resources and prompts from every configured upstream and publishes namespaced public values only after collision checks succeed. It names resources and prompts `__`, and exposes resources as `miftah://resource/?uri=`. The registry retains the original upstream URI privately with the exact profile and upstream route. Prompt resource links and read-result sub-resources are registered as exact Miftah routes to their originating upstream. Before resource/prompt URI metadata crosses the boundary, structural redaction strips userinfo/fragments and redacts query values, including URI metadata returned by reads and prompt content. After the operation pipeline authorizes its selected profile, a read or prompt get resolves that exact route and forwards only to its originating upstream; unknown identifiers are rejected rather than forwarded. Aggregate pagination stores the individual upstream cursors behind opaque, bounded in-memory LRU state scoped to the profile and capability kind. diff --git a/docs/config.md b/docs/config.md index 17b62e9e..8ceb5eaa 100644 --- a/docs/config.md +++ b/docs/config.md @@ -54,6 +54,36 @@ The supported routing fallback values are: - `ask`: return `ROUTING_AMBIGUOUS` when no unique rule applies. - `block`: reject requests that do not match a rule. +## Routing context + +The explicit `--config` file is the only runtime configuration authority. Miftah never auto-loads a second configuration file. It treats a discovered `.miftahrc.json` or non-runtime `miftah.json` as a metadata-only project marker only when it has this exact shape: + +```json +{ + "profiles": { + "": "" + } +} +``` + +The marker's only top-level key must be `profiles`, and all values are strings. It may contain mappings for other wrapper names, but only `` is selected; that name is the trusted runtime configuration's `name`, and its `` must name one of the configured profiles. A marker cannot configure an upstream, policy, header, environment value, audit setting, secret source, or any other Miftah option; it does not merge with runtime configuration. Miftah reads only bounded known metadata files while walking from the process working directory to an applicable local boundary and ignores malformed, irrelevant, oversized, or out-of-boundary files. The nearest valid project marker wins; in one directory, `.miftahrc.json` is checked before `miftah.json`, and markers are not combined. + +The metadata-only collector can provide rules with `context.*` values from: + +- validated MCP client `file:` roots and the process working directory; +- `MIFTAH_PROFILE` and `MIFTAH_PROJECT`; +- the strict project marker above; +- bounded `package.json` name/repository and workspace metadata; +- the local Git `remote.origin.url`. + +`MIFTAH_PROFILE` must name a configured profile or routing fails closed. `MIFTAH_PROJECT` is contextual metadata only: Miftah does not interpret it as a path or probe the filesystem from it. URI-like project and repository values, Git origins, and roots are structurally sanitized before evidence crosses an MCP or audit boundary. + +Selection order is environment hint, project-marker hint, configured rule, then fallback. The marker stage is the one nearest valid marker above; matching rules that select different profiles return `ROUTING_AMBIGUOUS`, and Miftah does not guess. Context and profile hints do not replace the explicit-rule requirement for a destructive operation when `security.requireExplicitProfileForDestructive` is enabled. + +MCP roots are optional client metadata. After initialization, Miftah calls `roots/list` only when the client advertises `roots` capability, stores a URI-only snapshot for that connection, and refreshes it only on an advertised `notifications/roots/list_changed`. An unsupported or failed roots request yields an empty-root snapshot; Miftah does not poll roots or request them for every operation. + +`miftah_route_preview` resolves against one collector snapshot using the same context inputs as a proxied operation. Its response contains the selected profile, reason, policy decision, and sanitized `evidence`. Proxied operation audit records carry the same snapshot as additive `routingEvidence`, including an ambiguity that prevents forwarding. Evidence contains only allowlisted metadata and redacted URI components; it never contains arbitrary project file content or the raw `MIFTAH_PROJECT` value. + ## Operation routing and policy Miftah applies one safety pipeline to every proxied upstream tool call, resource read, and prompt retrieval. It captures the active profile once at request start, resolves the routing rule using that immutable fallback, evaluates the selected profile's policy before resolving an aggregate route or forwarding, redacts the result or error, and records the terminal operation metadata in audit output. @@ -62,7 +92,7 @@ Routing rules receive a tool's original arguments unchanged. Resource reads expo Policies classify these operation names as `read`, `write`, or `destructive` using configurable overrides and conservative name heuristics. `denyRisk` takes precedence over `allowRisk`; `requireConfirmation` returns a structured error instead of forwarding the operation. -Audit logging writes local JSONL when a path is configured. Every supported MCP request emits one terminal operation event with a request ID, per-process session ID, source/selected profiles, stable outcome/error code, duration, and any available upstream, routing, policy, and risk metadata; wrapper and upstream lifecycle transitions emit separate event records. Arguments are excluded unless `includeArguments` is true, and all configured secret values are redacted before writing. Audit directories and files are created with owner-only permissions where the platform supports them. +Audit logging writes local JSONL when a path is configured. Every supported MCP request emits one terminal operation event with a request ID, per-process session ID, source/selected profiles, stable outcome/error code, duration, and any available upstream, routing, policy, and risk metadata; route previews and proxied operations add sanitized `routingEvidence` when a collector snapshot is available. Wrapper and upstream lifecycle transitions emit separate event records. Arguments are excluded unless `includeArguments` is true, and all configured secret values are redacted before writing. Audit directories and files are created with owner-only permissions where the platform supports them. `audit.failureMode` accepts `"fail-closed"` (the default) or `"fail-open"`. Fail-closed verifies the configured sink before dispatch and refuses a request when the sink cannot be prepared; a terminal write error also surfaces as `AUDIT_WRITE_FAILED`. Because terminal writes occur after an upstream operation, that error can leave a non-idempotent operation's outcome indeterminate; do not blindly retry it. Fail-open preserves the request result and exposes a redacted `AUDIT_WRITE_FAILED` entry through `miftah_health`; it should be used only when availability is more important than complete auditability. diff --git a/docs/security.md b/docs/security.md index e4687d7e..a3b3f7ba 100644 --- a/docs/security.md +++ b/docs/security.md @@ -14,6 +14,10 @@ Miftah is a credential broker, so safe defaults are part of the product contract Audit writes default to fail-closed: Miftah verifies the configured sink before dispatch and refuses a request when the sink cannot be prepared. A terminal write can fail after an upstream side effect has completed, so a post-dispatch `AUDIT_WRITE_FAILED` has an indeterminate outcome and must not prompt a blind retry of a non-idempotent operation. An operator can set `audit.failureMode` to `"fail-open"` for availability-sensitive deployments; Miftah then preserves the request outcome but exposes a redacted `AUDIT_WRITE_FAILED` health entry. This mode trades complete auditability for availability. +The explicit runtime configuration is trusted operator input; workspace routing metadata is not. Project markers cannot inject configuration because their only accepted shape maps the configured wrapper name to an already-known profile. They cannot add credentials, environment variables, headers, upstreams, policies, audit controls, or secret references. Miftah reads only named, bounded metadata files within the applicable working-directory/root boundary and does not scan arbitrary project content. + +Routing evidence is deliberately narrower than routing context. It is passed through audit redaction before it reaches a client or JSONL record, strips URI userinfo/fragments and redacts URI query values, and never contains the raw `MIFTAH_PROJECT` value or arbitrary project file content. An unrecognized environment profile or ambiguous matching rules fails closed instead of selecting an account; standard project-marker discovery deterministically uses the nearest valid marker. Profile hints also cannot satisfy an explicit-rule requirement for destructive operations. + Miftah cannot reduce privileges granted by a provider token. A read-only Miftah policy is a local blocklist, not a replacement for provider-side scopes. Avoid putting real credentials in examples, commits, or support logs. `init` never asks for or echoes a secret value: its wizard accepts variable names and validated endpoint/header metadata only. Generated provider examples contain `${ENV_NAME}` references, and client snippets are printed for explicit copy rather than written into host configuration files. For exact preset pins, GitHub tag-to-digest recording guidance, Sentry skill-filtering limits, and client locations, see [preset and client compatibility](presets-and-clients.md). diff --git a/src/audit/audit-trail.ts b/src/audit/audit-trail.ts index fca8aba3..fbe57ba7 100644 --- a/src/audit/audit-trail.ts +++ b/src/audit/audit-trail.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { AuditLogger } from "./audit-logger.js"; import type { AuditEvent, AuditHealth, AuditRoutingSource, AuditStatus } from "./audit-types.js"; +import type { RoutingContextEvidence } from "../routing/routing-types.js"; export interface AuditOperationInput { operation: string; @@ -19,6 +20,7 @@ export interface AuditScopeUpdate { policyName?: string; policyDecision?: AuditEvent["policyDecision"]; risk?: AuditEvent["risk"]; + routingEvidence?: RoutingContextEvidence; } export interface AuditScopeResult { @@ -133,6 +135,7 @@ export class AuditScope { ...(this.event.policyName === undefined ? {} : { policyName: this.event.policyName }), ...(this.event.policyDecision === undefined ? {} : { policyDecision: this.event.policyDecision }), ...(this.event.risk === undefined ? {} : { risk: this.event.risk }), + ...(this.event.routingEvidence === undefined ? {} : { routingEvidence: this.event.routingEvidence }), ...(this.event.arguments === undefined ? {} : { arguments: this.event.arguments }), ...(result.errorCode === undefined ? {} : { errorCode: result.errorCode }) }); diff --git a/src/audit/audit-types.ts b/src/audit/audit-types.ts index 0fcbac4e..90a67808 100644 --- a/src/audit/audit-types.ts +++ b/src/audit/audit-types.ts @@ -1,4 +1,5 @@ import type { PolicyAction, RiskLevel } from "../policy/policy-types.js"; +import type { RoutingContextEvidence } from "../routing/routing-types.js"; export type AuditFailureMode = "fail-open" | "fail-closed"; @@ -36,6 +37,7 @@ export interface AuditEvent { policyName?: string; policyDecision?: PolicyAction; risk?: RiskLevel; + routingEvidence?: RoutingContextEvidence; arguments?: unknown; errorCode?: string; } diff --git a/src/mcp/server/miftah-server.ts b/src/mcp/server/miftah-server.ts index 9877c1c1..c934e4fe 100644 --- a/src/mcp/server/miftah-server.ts +++ b/src/mcp/server/miftah-server.ts @@ -3,6 +3,7 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { CallToolRequestSchema, GetPromptRequestSchema, + RootsListChangedNotificationSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, @@ -24,6 +25,10 @@ import type { MiftahConfig, ToolingConfig } from "../../config/types.js"; import { SecretRedactor, redactUri } from "../../secrets/redact.js"; import { ProfileManager } from "../../profiles/profile-manager.js"; import { RoutingEngine } from "../../routing/routing-engine.js"; +import type { + RoutingContextMcpRoot, + RoutingContextSnapshot +} from "../../routing/routing-types.js"; import { PolicyEngine } from "../../policy/policy-engine.js"; import { AuditLogger } from "../../audit/audit-logger.js"; import { AuditScope, AuditTrail, type AuditScopeResult } from "../../audit/audit-trail.js"; @@ -64,6 +69,17 @@ const managementTools: Tool[] = [ tool("miftah_route_preview", "Preview routing for a hypothetical tool call.", ["toolName"]) ]; +const EMPTY_MCP_ROOTS: readonly RoutingContextMcpRoot[] = Object.freeze([]); +const emptyRoutingContext: RoutingContextSnapshot = { + context: Object.freeze({}), + evidence: Object.freeze({ cwd: "", fileRoots: Object.freeze([]) }), + profileHints: Object.freeze([]) +}; + +export type RoutingContextCollector = ( + roots: readonly RoutingContextMcpRoot[] +) => Promise; + export function resolveClientVisibleToolName( name: string, upstreamName: string | undefined, @@ -143,11 +159,25 @@ export class MiftahServer { private readonly restartingProfiles = new Map>(); private readonly pendingResourceListChanges = new Set(); private readonly pendingPromptListChanges = new Set(); + private mcpRoots: readonly RoutingContextMcpRoot[] = EMPTY_MCP_ROOTS; + private mcpRootsReady: Promise = Promise.resolve(); + private resolveMcpRootsReady: () => void = () => undefined; + private mcpRootsRefresh?: Promise; + private mcpRootsRefreshRequested = false; + private mcpRootsConnection = 0; + private mcpRootsInitialized = false; + private readonly provideRoutingContext = async (): Promise => { + if (this.routingContextCollector === undefined) return emptyRoutingContext; + if (!this.mcpRootsInitialized) return this.routingContextCollector(EMPTY_MCP_ROOTS); + await this.mcpRootsReady; + return this.routingContextCollector(this.mcpRoots); + }; constructor( private readonly config: MiftahConfig, private readonly profiles: ProfileManager, - private readonly upstreams: UpstreamProcessManager | MultiUpstreamProcessManager + private readonly upstreams: UpstreamProcessManager | MultiUpstreamProcessManager, + private readonly routingContextCollector?: RoutingContextCollector ) { this.redactor = upstreams.getRedactor(); this.resourcePromptProxy = this.resourcePromptProxyAvailability(); @@ -205,12 +235,28 @@ export class MiftahServer { routing: this.routing, policy: this.policy, upstreams, - redactor: this.redactor + redactor: this.redactor, + routingContext: this.provideRoutingContext + }); + this.server.oninitialized = () => this.handleClientInitialized(); + this.server.setNotificationHandler(RootsListChangedNotificationSchema, () => { + if ( + this.routingContextCollector === undefined || + !this.mcpRootsInitialized || + this.server.getClientCapabilities()?.roots?.listChanged !== true + ) { + return; + } + void this.refreshMcpRoots().then( + () => undefined, + () => undefined + ); }); this.registerHandlers(); } async connect(transport: Transport): Promise { + this.resetMcpRoots(); await this.server.connect(transport); await this.auditTrail.writeLifecycle({ operation: "wrapper/start", @@ -232,6 +278,75 @@ export class MiftahServer { }).catch(() => undefined); } + private resetMcpRoots(): void { + this.mcpRootsConnection += 1; + this.mcpRoots = EMPTY_MCP_ROOTS; + this.mcpRootsRefresh = undefined; + this.mcpRootsRefreshRequested = false; + this.mcpRootsInitialized = false; + this.mcpRootsReady = new Promise((resolve) => { + this.resolveMcpRootsReady = resolve; + }); + } + + private handleClientInitialized(): void { + this.mcpRootsInitialized = true; + if (this.routingContextCollector === undefined || this.server.getClientCapabilities()?.roots === undefined) { + this.resolveMcpRootsReady(); + return; + } + void this.refreshMcpRoots().then( + () => this.resolveMcpRootsReady(), + () => this.resolveMcpRootsReady() + ); + } + + private refreshMcpRoots(): Promise { + const pending = this.mcpRootsRefresh; + if (pending) { + this.mcpRootsRefreshRequested = true; + return pending; + } + + const connection = this.mcpRootsConnection; + const refresh = this.refreshMcpRootsUntilCurrent(connection); + this.mcpRootsRefresh = refresh; + void refresh.then( + () => { + if (this.mcpRootsRefresh === refresh) this.mcpRootsRefresh = undefined; + }, + () => { + if (this.mcpRootsRefresh === refresh) this.mcpRootsRefresh = undefined; + } + ); + return refresh; + } + + private async refreshMcpRootsUntilCurrent(connection: number): Promise { + do { + this.mcpRootsRefreshRequested = false; + await this.fetchMcpRoots(connection); + } while (connection === this.mcpRootsConnection && this.mcpRootsRefreshRequested); + } + + private async fetchMcpRoots(connection: number): Promise { + if ( + this.routingContextCollector === undefined || + !this.mcpRootsInitialized || + this.server.getClientCapabilities()?.roots === undefined + ) { + if (connection === this.mcpRootsConnection) this.mcpRoots = EMPTY_MCP_ROOTS; + return; + } + try { + const result = await this.server.listRoots(); + if (connection !== this.mcpRootsConnection) return; + this.mcpRoots = Object.freeze(result.roots.map(({ uri }) => Object.freeze({ uri }))); + } catch { + if (connection === this.mcpRootsConnection) this.mcpRoots = EMPTY_MCP_ROOTS; + } + } + private registerHandlers(): void { this.server.setRequestHandler(ListToolsRequestSchema, async () => { const source = this.profiles.current(); @@ -260,7 +375,9 @@ export class MiftahServer { arguments: args }, (audit) => - isManagementTool ? this.handleManagement(name, args, audit) : this.handleUpstreamTool(name, args, audit, source), + isManagementTool + ? this.handleManagement(name, args, audit, source) + : this.handleUpstreamTool(name, args, audit, source), (error) => textResult(error.message, true), (result) => result.isError @@ -424,7 +541,8 @@ export class MiftahServer { private async handleManagement( name: string, args: Record, - audit: AuditScope + audit: AuditScope, + source: CapturedProfileState ): Promise { if (name === "miftah_list_profiles") { const activeProfile = this.profiles.current().activeProfile; @@ -497,13 +615,27 @@ export class MiftahServer { return textResult("Profile restarted."); } if (name === "miftah_route_preview") { + const toolName = requiredString(args, "toolName"); + const snapshot = await this.provideRoutingContext(); + const evidence = this.redactor.redactForAudit(snapshot.evidence); + audit.update({ routingEvidence: evidence }); const route = this.routing.resolve({ - toolName: requiredString(args, "toolName"), - args: isRecord(args.args) ? args.args : {} - }); + toolName, + args: isRecord(args.args) ? args.args : {}, + context: snapshot.context, + profileHints: snapshot.profileHints + }, source.activeProfile); const profile = this.profiles.get(route.profile); - audit.update({ profile: route.profile }); - return textResult(JSON.stringify({ ...route, policy: this.policy.evaluate(profile.policy, requiredString(args, "toolName")) })); + const policy = this.policy.evaluate(profile.policy, toolName); + audit.update({ + profile: route.profile, + routingReason: route.reason, + policyName: profile.policy ?? "default", + policyDecision: policy.action, + risk: policy.risk, + routingEvidence: evidence + }); + return textResult(JSON.stringify({ ...route, policy, evidence })); } throw new MiftahError("TOOL_NOT_FOUND", `TOOL_NOT_FOUND: management tool '${name}' is not registered`); } diff --git a/src/mcp/server/operation-pipeline.ts b/src/mcp/server/operation-pipeline.ts index 297d4a11..b21c965c 100644 --- a/src/mcp/server/operation-pipeline.ts +++ b/src/mcp/server/operation-pipeline.ts @@ -3,7 +3,7 @@ import { PolicyEngine } from "../../policy/policy-engine.js"; import type { PolicyDecision } from "../../policy/policy-types.js"; import { ProfileManager } from "../../profiles/profile-manager.js"; import { RoutingEngine } from "../../routing/routing-engine.js"; -import type { RoutingDecision } from "../../routing/routing-types.js"; +import type { RoutingContextSnapshot, RoutingDecision } from "../../routing/routing-types.js"; import { SecretRedactor } from "../../secrets/redact.js"; import { MultiUpstreamProcessManager } from "../../upstream/multi-upstream-process-manager.js"; import { UpstreamProcessManager } from "../../upstream/upstream-process-manager.js"; @@ -35,12 +35,15 @@ export interface ProxiedOperation { resolveTarget(profile: string): Promise>; } +export type RoutingContextProvider = () => Promise; + interface PipelineOptions { readonly profiles: ProfileManager; readonly routing: RoutingEngine; readonly policy: PolicyEngine; readonly upstreams: UpstreamProcessManager | MultiUpstreamProcessManager; readonly redactor: SecretRedactor; + readonly routingContext: RoutingContextProvider; } /** @@ -51,8 +54,15 @@ export class OperationPipeline { async execute(operation: ProxiedOperation, audit: AuditScope): Promise { try { + const snapshot = await this.options.routingContext(); + audit.update({ routingEvidence: this.options.redactor.redactForAudit(snapshot.evidence) }); const route = this.options.routing.resolve( - { toolName: operation.routingName, args: operation.args }, + { + toolName: operation.routingName, + args: operation.args, + context: snapshot.context, + profileHints: snapshot.profileHints + }, operation.source.activeProfile ); const profile = route.profile; diff --git a/src/routing/context-collector.ts b/src/routing/context-collector.ts new file mode 100644 index 00000000..33d8d635 --- /dev/null +++ b/src/routing/context-collector.ts @@ -0,0 +1,397 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { lstat, open, realpath, type FileHandle } from "node:fs/promises"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { redactUri } from "../secrets/redact.js"; +import { MiftahError } from "../utils/errors.js"; +import type { + RoutingContextCollectorInput, + RoutingContextEvidence, + RoutingContextMcpRoot, + RoutingContextProfileHint, + RoutingContextSnapshot +} from "./routing-types.js"; + +const execFile = promisify(execFileCallback); + +/** Maximum bytes read from a known JSON metadata file before it is ignored. */ +export const MAX_ROUTING_CONTEXT_JSON_BYTES = 64 * 1024; +const uriSchemePattern = /^[a-z][a-z0-9+.-]*:/i; +const windowsDrivePattern = /^[a-z]:[\\/]/i; + +interface ProjectMarker { + readonly profile: string; + readonly path: string; +} + +interface PackageMetadata { + readonly path: string; + readonly name?: string; + readonly repository?: string; + readonly workspace: boolean; +} + +interface KnownJsonFile { + readonly found: boolean; + readonly value?: unknown; +} + +/** + * Collects bounded, allowlisted metadata for later routing decisions. + * It does not select a profile or load runtime configuration. + */ +export async function collectRoutingContext( + input: RoutingContextCollectorInput +): Promise { + const cwd = resolve(input.cwd); + const fileRoots = normalizeFileRoots(input.mcpRoots ?? []); + const boundary = selectBoundary(cwd, fileRoots); + const metadataScope = await resolveMetadataScope(cwd, boundary); + const environment = collectEnvironment(input.environment); + const marker = metadataScope + ? await findProjectMarker( + metadataScope.cwd, + metadataScope.boundary, + input.wrapperName, + input.runtimeConfigPath, + input.knownProfileNames + ) + : undefined; + const packages = metadataScope + ? await findPackageMetadata(metadataScope.cwd, metadataScope.boundary) + : {}; + const gitOrigin = metadataScope + ? await findGitOrigin( + metadataScope.cwd, + metadataScope.boundary, + input.gitExecutable ?? "git" + ) + : undefined; + + const hints: RoutingContextProfileHint[] = []; + if (environment.profile !== undefined) { + assertKnownProfile(environment.profile, input.knownProfileNames); + hints.push({ + profile: environment.profile, + source: "environment", + evidence: { kind: "environment", variable: "MIFTAH_PROFILE" } + }); + } + if (marker) { + hints.push({ + profile: marker.profile, + source: "project-marker", + evidence: { kind: "marker", path: marker.path } + }); + } + + const context: Record = { cwd, fileRoots }; + if (environment.profile !== undefined || environment.project !== undefined) { + context.environment = { + ...(environment.profile === undefined ? {} : { profile: environment.profile }), + ...(environment.project === undefined ? {} : { project: environment.project }) + }; + } + if (marker) context.marker = { profile: marker.profile }; + if (packages.nearest) context.package = contextPackage(packages.nearest); + if (packages.workspace) context.workspace = contextPackage(packages.workspace); + if (gitOrigin) context.git = { origin: gitOrigin }; + + const evidence: RoutingContextEvidence = { + cwd, + fileRoots, + ...(environment.profile === undefined && environment.project === undefined + ? {} + : { + environment: { + ...(environment.profile === undefined ? {} : { profile: environment.profile }), + ...(environment.project === undefined ? {} : { hasProject: true as const }) + } + }), + ...(marker ? { marker: { path: marker.path } } : {}), + ...(packages.nearest ? { package: evidencePackage(packages.nearest) } : {}), + ...(packages.workspace ? { workspace: evidencePackage(packages.workspace) } : {}), + ...(gitOrigin ? { git: { origin: gitOrigin } } : {}) + }; + + return deepFreeze({ context, evidence, profileHints: hints }); +} + +function collectEnvironment(environment: Readonly>): { + profile?: string; + project?: string; +} { + return { + ...(environment.MIFTAH_PROFILE === undefined ? {} : { profile: environment.MIFTAH_PROFILE }), + ...(environment.MIFTAH_PROJECT === undefined + ? {} + : { project: redactProjectValue(environment.MIFTAH_PROJECT) }) + }; +} + +function redactProjectValue(value: string): string { + if (uriSchemePattern.test(value) && !windowsDrivePattern.test(value)) { + return redactUri(value); + } + return value; +} + +function normalizeFileRoots(roots: readonly (string | RoutingContextMcpRoot)[]): string[] { + const normalized = new Set(); + for (const root of roots) { + const uri = typeof root === "string" ? root : root.uri; + if (typeof uri !== "string") continue; + try { + const parsed = new URL(uri); + if (parsed.protocol !== "file:") continue; + parsed.search = ""; + parsed.hash = ""; + const path = resolve(fileURLToPath(parsed)); + normalized.add(pathToFileURL(path).toString()); + } catch { + // Client-provided root metadata is optional and malformed roots are ignored. + } + } + return [...normalized].sort(); +} + +function selectBoundary(cwd: string, fileRootUris: readonly string[]): string { + const candidates = fileRootUris + .map((uri) => fileURLToPath(uri)) + .filter((root) => isWithin(root, cwd)) + .sort((first, second) => second.length - first.length); + return candidates[0] ?? cwd; +} + +async function resolveMetadataScope( + cwd: string, + boundary: string +): Promise<{ cwd: string; boundary: string } | undefined> { + try { + const [resolvedCwd, resolvedBoundary] = await Promise.all([realpath(cwd), realpath(boundary)]); + return isWithin(resolvedBoundary, resolvedCwd) + ? { cwd: resolvedCwd, boundary: resolvedBoundary } + : undefined; + } catch { + return undefined; + } +} + +async function findProjectMarker( + cwd: string, + boundary: string, + wrapperName: string, + runtimeConfigPath: string | undefined, + knownProfileNames: readonly string[] +): Promise { + const runtimePath = runtimeConfigPath ? await canonicalPath(runtimeConfigPath) : undefined; + for (const directory of ancestors(cwd, boundary)) { + for (const name of [".miftahrc.json", "miftah.json"]) { + const path = resolve(directory, name); + if (path === runtimePath) continue; + const file = await readKnownJson(path); + if (!file.found) continue; + const profile = strictMarkerProfile(file.value, wrapperName); + if (profile === undefined) continue; + assertKnownProfile(profile, knownProfileNames); + return { profile, path }; + } + } + return undefined; +} + +async function canonicalPath(path: string): Promise { + const resolvedPath = resolve(path); + try { + return await realpath(resolvedPath); + } catch { + return resolvedPath; + } +} + +async function findPackageMetadata( + cwd: string, + boundary: string +): Promise<{ nearest?: PackageMetadata; workspace?: PackageMetadata }> { + let nearest: PackageMetadata | undefined; + let workspace: PackageMetadata | undefined; + for (const directory of ancestors(cwd, boundary)) { + const path = resolve(directory, "package.json"); + const file = await readKnownJson(path); + if (!file.found) continue; + const metadata = packageMetadata(file.value, path); + if (!metadata) continue; + nearest ??= metadata; + if (metadata.workspace) workspace ??= metadata; + } + return { ...(nearest ? { nearest } : {}), ...(workspace ? { workspace } : {}) }; +} + +async function readKnownJson(path: string): Promise { + let stats; + try { + stats = await lstat(path); + } catch { + return { found: false }; + } + if (!stats.isFile() || stats.size > MAX_ROUTING_CONTEXT_JSON_BYTES) return { found: true }; + + let file: FileHandle | undefined; + try { + file = await open(path, "r"); + const openedStats = await file.stat(); + // Do not read through a symlink swapped in after the path was inspected. + if ( + !openedStats.isFile() || + openedStats.size > MAX_ROUTING_CONTEXT_JSON_BYTES || + openedStats.dev !== stats.dev || + openedStats.ino !== stats.ino + ) { + return { found: true }; + } + const source = await file.readFile({ encoding: "utf8" }); + return { found: true, value: JSON.parse(source) as unknown }; + } catch { + return { found: true }; + } finally { + await file?.close(); + } +} + +function strictMarkerProfile(value: unknown, wrapperName: string): string | undefined { + if (!isRecord(value) || !hasOnlyKeys(value, ["profiles"]) || !isRecord(value.profiles)) return undefined; + for (const profile of Object.values(value.profiles)) { + if (typeof profile !== "string") return undefined; + } + const selected = value.profiles[wrapperName]; + return typeof selected === "string" ? selected : undefined; +} + +function packageMetadata(value: unknown, path: string): PackageMetadata | undefined { + if (!isRecord(value)) return undefined; + const name = typeof value.name === "string" ? value.name : undefined; + const repository = repositoryUri(value.repository); + const workspace = isWorkspaceDeclaration(value.workspaces); + if (name === undefined && repository === undefined && !workspace) return undefined; + return { + path, + ...(name === undefined ? {} : { name }), + ...(repository === undefined ? {} : { repository }), + workspace + }; +} + +function repositoryUri(value: unknown): string | undefined { + const raw = + typeof value === "string" + ? value + : isRecord(value) && typeof value.url === "string" + ? value.url + : undefined; + return raw === undefined ? undefined : redactUri(raw); +} + +function isWorkspaceDeclaration(value: unknown): boolean { + if (Array.isArray(value)) return value.every((entry) => typeof entry === "string"); + return ( + isRecord(value) && + Array.isArray(value.packages) && + value.packages.every((entry) => typeof entry === "string") + ); +} + +function contextPackage(metadata: PackageMetadata): Record { + return { + ...(metadata.name === undefined ? {} : { name: metadata.name }), + ...(metadata.repository === undefined ? {} : { repository: metadata.repository }) + }; +} + +function evidencePackage(metadata: PackageMetadata): { + path: string; + name?: string; + repository?: string; +} { + return { path: metadata.path, ...contextPackage(metadata) }; +} + +async function findGitOrigin( + cwd: string, + boundary: string, + executable: string +): Promise { + try { + const { stdout: gitDirectory } = await execFile( + executable, + ["rev-parse", "--git-common-dir"], + gitExecutionOptions(cwd, boundary) + ); + const resolvedGitDirectory = await realpath(resolve(cwd, gitDirectory.trim())); + if (!isWithin(boundary, resolvedGitDirectory)) return undefined; + + const { stdout } = await execFile( + executable, + ["config", "--local", "--no-includes", "--get", "remote.origin.url"], + gitExecutionOptions(cwd, boundary) + ); + const origin = stdout.trim(); + return origin.length === 0 ? undefined : redactUri(origin); + } catch { + return undefined; + } +} + +function gitExecutionOptions(cwd: string, boundary: string): { + cwd: string; + maxBuffer: number; + env: NodeJS.ProcessEnv; +} { + return { + cwd, + maxBuffer: MAX_ROUTING_CONTEXT_JSON_BYTES, + env: { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))), + GIT_CEILING_DIRECTORIES: dirname(boundary) + } + }; +} + +function assertKnownProfile(profile: string, profiles: readonly string[]): void { + if (!profiles.includes(profile)) { + throw new MiftahError( + "ROUTING_PROFILE_NOT_FOUND", + "ROUTING_PROFILE_NOT_FOUND: routing context hint is not a configured profile" + ); + } +} + +function* ancestors(cwd: string, boundary: string): Generator { + let current = cwd; + while (isWithin(boundary, current)) { + yield current; + if (current === boundary) return; + const parent = dirname(current); + if (parent === current) return; + current = parent; + } +} + +function isWithin(root: string, path: string): boolean { + const value = relative(root, path); + return value === "" || (!value.startsWith("..") && !value.includes(`..${process.platform === "win32" ? "\\" : "/"}`)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean { + return Object.keys(value).every((key) => allowed.includes(key)); +} + +function deepFreeze(value: T): T { + if (!value || typeof value !== "object") return value; + for (const child of Object.values(value)) deepFreeze(child); + return Object.freeze(value); +} diff --git a/src/routing/routing-engine.ts b/src/routing/routing-engine.ts index 23770bec..0db4048b 100644 --- a/src/routing/routing-engine.ts +++ b/src/routing/routing-engine.ts @@ -14,14 +14,27 @@ function getPath(input: RoutingInput, path: string): unknown { function matches(rule: RoutingRule, input: RoutingInput): boolean { return Object.entries(rule.when).every(([path, expected]) => { const actual = getPath(input, path); - if (typeof expected === "string" && expected.includes("*")) { - const pattern = new RegExp(`^${expected.split("*").map(escapeRegExp).join(".*")}$`); - return typeof actual === "string" && pattern.test(actual); + + if (path.startsWith("context.") && Array.isArray(actual)) { + return actual.some((value) => isScalar(value) && isScalar(expected) && matchesValue(value, expected)); } - return actual === expected; + + return matchesValue(actual, expected); }); } +function matchesValue(actual: unknown, expected: unknown): boolean { + if (typeof expected === "string" && expected.includes("*")) { + const pattern = new RegExp(`^${expected.split("*").map(escapeRegExp).join(".*")}$`); + return typeof actual === "string" && pattern.test(actual); + } + return actual === expected; +} + +function isScalar(value: unknown): boolean { + return value === null || (typeof value !== "object" && typeof value !== "function"); +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -38,6 +51,28 @@ export class RoutingEngine { } resolve(input: RoutingInput, activeProfile = this.activeProfile): RoutingDecision { + const environmentHint = input.profileHints?.find((hint) => hint.source === "environment"); + if (environmentHint) { + return { profile: environmentHint.profile, reason: "profile-hint:environment" }; + } + + const markerProfiles = [ + ...new Set( + (input.profileHints ?? []) + .filter((hint) => hint.source === "project-marker") + .map((hint) => hint.profile) + ) + ].sort(); + if (markerProfiles.length > 1) { + throw new MiftahError( + "ROUTING_AMBIGUOUS", + `ROUTING_AMBIGUOUS: project-marker profile hints are ${markerProfiles.join(", ")}` + ); + } + if (markerProfiles.length === 1) { + return { profile: markerProfiles[0]!, reason: "profile-hint:project-marker" }; + } + const matchingRules = (this.config.rules ?? []).filter((rule) => matches(rule, input)); const profiles = [...new Set(matchingRules.map((rule) => rule.profile))]; if (profiles.length > 1) { diff --git a/src/routing/routing-types.ts b/src/routing/routing-types.ts index e090748d..4af2f15c 100644 --- a/src/routing/routing-types.ts +++ b/src/routing/routing-types.ts @@ -4,6 +4,7 @@ export interface RoutingInput { toolName: string; args?: Record; context?: Record; + profileHints?: readonly RoutingContextProfileHint[]; } export interface RoutingDecision { @@ -11,4 +12,53 @@ export interface RoutingDecision { reason: string; } +/** A root supplied by an MCP client during initialization. */ +export interface RoutingContextMcpRoot { + readonly uri: string; +} + +/** Inputs accepted by the internal, metadata-only routing context collector. */ +export interface RoutingContextCollectorInput { + readonly wrapperName: string; + readonly knownProfileNames: readonly string[]; + readonly cwd: string; + readonly environment: Readonly>; + readonly runtimeConfigPath?: string; + readonly mcpRoots?: readonly (string | RoutingContextMcpRoot)[]; + readonly gitExecutable?: string; +} + +export interface RoutingContextProfileHint { + readonly profile: string; + readonly source: "environment" | "project-marker"; + readonly evidence: + | { readonly kind: "environment"; readonly variable: "MIFTAH_PROFILE" } + | { readonly kind: "marker"; readonly path: string }; +} + +export interface RoutingContextEvidence { + readonly cwd: string; + readonly fileRoots: readonly string[]; + readonly environment?: { readonly profile?: string; readonly hasProject?: true }; + readonly marker?: { readonly path: string }; + readonly package?: { + readonly path: string; + readonly name?: string; + readonly repository?: string; + }; + readonly workspace?: { + readonly path: string; + readonly name?: string; + readonly repository?: string; + }; + readonly git?: { readonly origin: string }; +} + +/** Immutable collector output for later routing, preview, and audit integration. */ +export interface RoutingContextSnapshot { + readonly context: Record; + readonly evidence: RoutingContextEvidence; + readonly profileHints: readonly RoutingContextProfileHint[]; +} + export type { RoutingConfig }; diff --git a/src/runtime/create-miftah-runtime.ts b/src/runtime/create-miftah-runtime.ts index 89f76be8..54feffb2 100644 --- a/src/runtime/create-miftah-runtime.ts +++ b/src/runtime/create-miftah-runtime.ts @@ -1,6 +1,8 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { resolvePath } from "../config/path-resolve.js"; import type { MiftahConfig } from "../config/types.js"; import { MiftahServer } from "../mcp/server/miftah-server.js"; +import { collectRoutingContext } from "../routing/context-collector.js"; import { createRuntime } from "./create-runtime.js"; /** Encapsulates a configured Miftah MCP server and its managed upstream lifecycle. */ @@ -15,8 +17,22 @@ export interface MiftahRuntime { /** Creates an MCP wrapper runtime without exposing its internal manager or server classes. */ export async function createMiftahRuntime(configPath: string): Promise { - const runtime = await createRuntime(configPath); - const server = new MiftahServer(runtime.config, runtime.profileManager, runtime.manager); + const runtimeConfigPath = resolvePath(configPath); + const runtime = await createRuntime(runtimeConfigPath); + const server = new MiftahServer( + runtime.config, + runtime.profileManager, + runtime.manager, + (mcpRoots) => + collectRoutingContext({ + wrapperName: runtime.config.name, + knownProfileNames: Object.keys(runtime.config.profiles), + cwd: process.cwd(), + environment: process.env, + runtimeConfigPath, + mcpRoots + }) + ); return { config: runtime.config, diff --git a/tests/mcp-wrapper.test.ts b/tests/mcp-wrapper.test.ts index 5fe745f0..07a2ddbf 100644 --- a/tests/mcp-wrapper.test.ts +++ b/tests/mcp-wrapper.test.ts @@ -1,21 +1,125 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { CallToolResultSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"; -import { access, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import type { Transport, TransportSendOptions } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { + CallToolResultSchema, + ListRootsRequestSchema, + RootsListChangedNotificationSchema, + ToolListChangedNotificationSchema +} from "@modelcontextprotocol/sdk/types.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { expectExactlyOneNotification } from "./helpers/notifications.js"; import { validateConfig } from "../src/config/validate-config.js"; import type { MiftahConfig } from "../src/config/types.js"; import { ProfileManager } from "../src/profiles/profile-manager.js"; import { MiftahServer } from "../src/mcp/server/miftah-server.js"; +import { createMiftahRuntime } from "../src/runtime/create-miftah-runtime.js"; +import type { RoutingContextSnapshot } from "../src/routing/routing-types.js"; import { UpstreamProcessManager } from "../src/upstream/upstream-process-manager.js"; const fixture = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "fake-upstream.mjs"); const toolCollisionPattern = /TOOL_COLLISION/; +interface RuntimeRoutingFixture { + readonly directory: string; + readonly configPath: string; + readonly matchingRoot: string; + readonly changedRoot: string; +} + +async function createRuntimeRoutingFixture(workEnvironment: Record = {}): Promise { + const directory = await mkdtemp(join(process.cwd(), ".miftah-routing-context-")); + const matchingRoot = pathToFileURL(join(directory, "matching-root")).toString(); + const changedRoot = pathToFileURL(join(directory, "changed-root")).toString(); + const configPath = join(directory, "miftah.json"); + await writeFile( + configPath, + JSON.stringify({ + version: "1", + name: "accounts", + defaultProfile: "work", + upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, + profiles: { + work: { env: { TEST_ACCOUNT_NAME: "work", ...workEnvironment } }, + personal: { env: { TEST_ACCOUNT_NAME: "personal" } } + }, + routing: { + rules: [ + { + name: "matching-root", + when: { "context.fileRoots": matchingRoot }, + profile: "personal" + } + ] + } + }) + ); + return { directory, configPath, matchingRoot, changedRoot }; +} + +function withoutMiftahProfile(): () => void { + const profile = process.env.MIFTAH_PROFILE; + delete process.env.MIFTAH_PROFILE; + return () => { + if (profile === undefined) delete process.env.MIFTAH_PROFILE; + else process.env.MIFTAH_PROFILE = profile; + }; +} + +class DropInitializedNotificationTransport implements Transport { + constructor(private readonly delegate: Transport) {} + + get onclose(): Transport["onclose"] { + return this.delegate.onclose; + } + + set onclose(handler: Transport["onclose"]) { + this.delegate.onclose = handler; + } + + get onerror(): Transport["onerror"] { + return this.delegate.onerror; + } + + set onerror(handler: Transport["onerror"]) { + this.delegate.onerror = handler; + } + + get onmessage(): Transport["onmessage"] { + return this.delegate.onmessage; + } + + set onmessage(handler: Transport["onmessage"]) { + this.delegate.onmessage = handler; + } + + get sessionId(): string | undefined { + return this.delegate.sessionId; + } + + get setProtocolVersion(): Transport["setProtocolVersion"] { + return this.delegate.setProtocolVersion; + } + + async start(): Promise { + await this.delegate.start(); + } + + async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + if ("method" in message && message.method === "notifications/initialized") return; + await this.delegate.send(message, options); + } + + async close(): Promise { + await this.delegate.close(); + } +} + describe("Miftah MCP wrapper", () => { it("exposes management and upstream capabilities while routing calls by active profile", async () => { const config = validateConfig({ @@ -70,6 +174,197 @@ describe("Miftah MCP wrapper", () => { await wrapper.close(); }); + it("uses the collector snapshot for matching redacted preview and audit evidence", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-route-evidence-")); + const auditPath = join(directory, "audit.jsonl"); + const rawProject = "private-project-identity"; + const rawToken = "collector-secret-token"; + const contextPath = join(directory, "project"); + const snapshot: RoutingContextSnapshot = { + context: { + package: { name: "@example/personal-project" }, + environment: { project: rawProject } + }, + evidence: { + cwd: contextPath, + fileRoots: [`${pathToFileURL(contextPath).toString()}?token=${rawToken}`], + environment: { hasProject: true }, + package: { + path: join(contextPath, "package.json"), + name: "@example/personal-project", + repository: `https://${rawToken}@github.com/example/personal-project.git?token=${rawToken}` + } + }, + profileHints: [] + }; + const config = validateConfig({ + version: "1", + name: "accounts", + defaultProfile: "work", + upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, + profiles: { + work: { env: { TEST_ACCOUNT_NAME: "work" } }, + personal: { env: { TEST_ACCOUNT_NAME: "personal" } } + }, + routing: { + rules: [{ name: "personal-project", when: { "context.package.name": "@example/personal-project" }, profile: "personal" }] + }, + audit: { path: auditPath } + }); + const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer(config, new ProfileManager(config), manager, async () => snapshot); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }); + + try { + await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]); + + const previewResult = CallToolResultSchema.parse( + await client.callTool({ + name: "miftah_route_preview", + arguments: { toolName: "whoami" } + }) + ); + const previewContent = previewResult.content[0]; + if (previewContent?.type !== "text") throw new Error("Expected route preview text."); + const preview = JSON.parse(previewContent.text) as Record; + expect(preview).toMatchObject({ profile: "personal", reason: "rule:personal-project" }); + expect(JSON.stringify(preview.evidence)).not.toContain(rawProject); + expect(JSON.stringify(preview.evidence)).not.toContain(rawToken); + + expect(await client.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + content: [{ type: "text", text: "personal" }] + }); + + const events = (await readFile(auditPath, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + const operation = events.find((event) => event.kind === "operation" && event.operation === "tools/call" && event.name === "whoami"); + expect(operation).toMatchObject({ profile: "personal", routingReason: "rule:personal-project" }); + expect(operation?.routingEvidence).toEqual(preview.evidence); + expect(JSON.stringify(operation?.routingEvidence)).not.toContain(rawProject); + expect(JSON.stringify(operation?.routingEvidence)).not.toContain(rawToken); + } finally { + await client.close(); + await wrapper.close(); + } + }); + + it("records collector evidence when route preview context is ambiguous", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-preview-ambiguous-")); + const auditPath = join(directory, "audit.jsonl"); + const snapshot: RoutingContextSnapshot = { + context: {}, + evidence: { + cwd: join(directory, "project"), + fileRoots: [], + marker: { path: join(directory, "project", ".miftahrc.json") } + }, + profileHints: [ + { + profile: "work", + source: "project-marker", + evidence: { kind: "marker", path: join(directory, "work", ".miftahrc.json") } + }, + { + profile: "personal", + source: "project-marker", + evidence: { kind: "marker", path: join(directory, "personal", ".miftahrc.json") } + } + ] + }; + const config = validateConfig({ + version: "1", + name: "accounts", + defaultProfile: "work", + upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, + profiles: { + work: { env: { TEST_ACCOUNT_NAME: "work" } }, + personal: { env: { TEST_ACCOUNT_NAME: "personal" } } + }, + audit: { path: auditPath } + }); + const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer(config, new ProfileManager(config), manager, async () => snapshot); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }); + + try { + await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]); + + expect(await client.callTool({ name: "miftah_route_preview", arguments: { toolName: "whoami" } })).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("ROUTING_AMBIGUOUS") }] + }); + + const events = (await readFile(auditPath, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect(events.find((event) => event.kind === "operation" && event.operation === "management/route-preview")).toMatchObject({ + status: "ambiguous", + errorCode: "ROUTING_AMBIGUOUS", + routingEvidence: snapshot.evidence + }); + } finally { + await client.close(); + await wrapper.close(); + } + }); + + it("keeps a route preview bound to its captured fallback profile", async () => { + let resolveSnapshotStarted: () => void = () => undefined; + const snapshotStarted = new Promise((resolve) => { + resolveSnapshotStarted = resolve; + }); + let releaseSnapshot: () => void = () => undefined; + const snapshotGate = new Promise((resolve) => { + releaseSnapshot = resolve; + }); + const snapshot: RoutingContextSnapshot = { + context: {}, + evidence: { cwd: process.cwd(), fileRoots: [] }, + profileHints: [] + }; + const config = validateConfig({ + version: "1", + name: "accounts", + defaultProfile: "work", + upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, + profiles: { + work: { env: { TEST_ACCOUNT_NAME: "work" } }, + personal: { env: { TEST_ACCOUNT_NAME: "personal" } } + }, + audit: { enabled: false } + }); + const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer(config, new ProfileManager(config), manager, async () => { + resolveSnapshotStarted(); + await snapshotGate; + return snapshot; + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }); + + try { + await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]); + + const preview = client.callTool({ name: "miftah_route_preview", arguments: { toolName: "whoami" } }); + await snapshotStarted; + await client.callTool({ name: "miftah_use_profile", arguments: { profile: "personal" } }); + releaseSnapshot(); + + const previewResult = CallToolResultSchema.parse(await preview); + const previewContent = previewResult.content[0]; + if (previewContent?.type !== "text") throw new Error("Expected route preview text."); + expect(JSON.parse(previewContent.text)).toMatchObject({ profile: "work", reason: "active-profile" }); + } finally { + await client.close(); + await wrapper.close(); + } + }); + it("blocks destructive calls when runtime policy lookup misses an explicitly named policy", async () => { const config: MiftahConfig = { version: "1", @@ -740,4 +1035,432 @@ describe("Miftah MCP wrapper", () => { await wrapper.close(); } }); + + it("collects runtime roots once and routes repeated proxied calls from the cached context", async () => { + const restoreProfile = withoutMiftahProfile(); + const fixture = await createRuntimeRoutingFixture(); + const runtime = await createMiftahRuntime(fixture.configPath); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client( + { name: "root-capable-client", version: "1.0.0" }, + { capabilities: { roots: { listChanged: true } } } + ); + let rootRequests = 0; + client.setRequestHandler(ListRootsRequestSchema, async () => { + rootRequests += 1; + return { + roots: [{ uri: fixture.matchingRoot, name: "matching", _meta: { ignored: true } }] + }; + }); + + try { + await Promise.all([runtime.connect(serverTransport), client.connect(clientTransport)]); + + expect(await client.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + content: [{ type: "text", text: "personal" }] + }); + expect(rootRequests).toBe(1); + expect(await client.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + content: [{ type: "text", text: "personal" }] + }); + expect(rootRequests).toBe(1); + } finally { + await client.close(); + await runtime.close(); + await rm(fixture.directory, { recursive: true, force: true }); + restoreProfile(); + } + }); + + it("routes proxied calls with empty roots when the initialized notification is dropped", async () => { + const restoreProfile = withoutMiftahProfile(); + const fixture = await createRuntimeRoutingFixture(); + const runtime = await createMiftahRuntime(fixture.configPath); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client( + { name: "initialization-dropping-client", version: "1.0.0" }, + { capabilities: { roots: { listChanged: true } } } + ); + let rootRequests = 0; + client.setRequestHandler(ListRootsRequestSchema, async () => { + rootRequests += 1; + return { roots: [{ uri: fixture.matchingRoot }] }; + }); + + try { + await Promise.all([ + runtime.connect(serverTransport), + client.connect(new DropInitializedNotificationTransport(clientTransport)) + ]); + + expect(client.getServerCapabilities()).toMatchObject({ tools: { listChanged: true } }); + expect(rootRequests).toBe(0); + expect( + await client.callTool({ name: "whoami", arguments: {} }, CallToolResultSchema, { timeout: 500 }) + ).toMatchObject({ content: [{ type: "text", text: "work" }] }); + expect(rootRequests).toBe(0); + } finally { + await client.close(); + await runtime.close(); + await rm(fixture.directory, { recursive: true, force: true }); + restoreProfile(); + } + }); + + it("keeps a relative runtime config distinct from a later project marker", async () => { + const restoreProfile = withoutMiftahProfile(); + const originalCwd = process.cwd(); + const directory = await mkdtemp(join(originalCwd, ".miftah-runtime-config-path-")); + const runtimeDirectory = join(directory, "runtime"); + const projectDirectory = join(directory, "project"); + let runtime: Awaited> | undefined; + let client: Client | undefined; + + try { + await Promise.all([mkdir(runtimeDirectory), mkdir(projectDirectory)]); + await writeFile( + join(runtimeDirectory, "miftah.json"), + JSON.stringify({ + version: "1", + name: "accounts", + defaultProfile: "work", + upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, + profiles: { + work: { env: { TEST_ACCOUNT_NAME: "work" } }, + personal: { env: { TEST_ACCOUNT_NAME: "personal" } } + } + }) + ); + await writeFile(join(projectDirectory, "miftah.json"), JSON.stringify({ profiles: { accounts: "personal" } })); + + process.chdir(runtimeDirectory); + runtime = await createMiftahRuntime("miftah.json"); + process.chdir(projectDirectory); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + client = new Client( + { name: "relative-runtime-config-client", version: "1.0.0" }, + { capabilities: { roots: {} } } + ); + client.setRequestHandler(ListRootsRequestSchema, async () => ({ + roots: [{ uri: pathToFileURL(projectDirectory).toString() }] + })); + + await Promise.all([runtime.connect(serverTransport), client.connect(clientTransport)]); + expect(await client.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + content: [{ type: "text", text: "personal" }] + }); + } finally { + await client?.close(); + await runtime?.close(); + process.chdir(originalCwd); + await rm(directory, { recursive: true, force: true }); + restoreProfile(); + } + }); + + it("keeps fallback routing usable without roots and preserves direct-server empty context", async () => { + const restoreProfile = withoutMiftahProfile(); + const routingFixture = await createRuntimeRoutingFixture(); + const runtime = await createMiftahRuntime(routingFixture.configPath); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const capabilities: { roots?: Record } = { roots: {} }; + const client = new Client( + { name: "roots-disabled-client", version: "1.0.0" }, + { capabilities } + ); + let rootRequests = 0; + client.setRequestHandler(ListRootsRequestSchema, async () => { + rootRequests += 1; + return { roots: [] }; + }); + delete capabilities.roots; + + const config = validateConfig({ + version: "1", + name: "accounts", + defaultProfile: "work", + upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, + profiles: { work: { env: { TEST_ACCOUNT_NAME: "work" } } } + }); + const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer(config, new ProfileManager(config), manager); + const [directClientTransport, directServerTransport] = InMemoryTransport.createLinkedPair(); + const directClient = new Client( + { name: "direct-root-capable-client", version: "1.0.0" }, + { capabilities: { roots: { listChanged: true } } } + ); + let directRootRequests = 0; + directClient.setRequestHandler(ListRootsRequestSchema, async () => { + directRootRequests += 1; + return { roots: [] }; + }); + + try { + await Promise.all([runtime.connect(serverTransport), client.connect(clientTransport)]); + expect(await client.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + content: [{ type: "text", text: "work" }] + }); + expect(rootRequests).toBe(0); + + await Promise.all([wrapper.connect(directServerTransport), directClient.connect(directClientTransport)]); + expect(await directClient.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + content: [{ type: "text", text: "work" }] + }); + expect(directRootRequests).toBe(0); + } finally { + await client.close(); + await runtime.close(); + await directClient.close(); + await wrapper.close(); + await rm(routingFixture.directory, { recursive: true, force: true }); + restoreProfile(); + } + }); + + it("falls back after a failed runtime roots request without retrying per operation", async () => { + const restoreProfile = withoutMiftahProfile(); + const fixture = await createRuntimeRoutingFixture(); + const runtime = await createMiftahRuntime(fixture.configPath); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client( + { name: "failing-roots-client", version: "1.0.0" }, + { capabilities: { roots: { listChanged: true } } } + ); + let rootRequests = 0; + client.setRequestHandler(ListRootsRequestSchema, async () => { + rootRequests += 1; + throw new Error("roots unavailable"); + }); + + try { + await Promise.all([runtime.connect(serverTransport), client.connect(clientTransport)]); + + expect(await client.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + content: [{ type: "text", text: "work" }] + }); + expect(await client.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + content: [{ type: "text", text: "work" }] + }); + expect(rootRequests).toBe(1); + } finally { + await client.close(); + await runtime.close(); + await rm(fixture.directory, { recursive: true, force: true }); + restoreProfile(); + } + }); + + it("refreshes advertised roots once and ignores unadvertised roots changes", async () => { + const restoreProfile = withoutMiftahProfile(); + const fixture = await createRuntimeRoutingFixture(); + const runtime = await createMiftahRuntime(fixture.configPath); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client( + { name: "root-change-client", version: "1.0.0" }, + { capabilities: { roots: { listChanged: true } } } + ); + let currentRoot = fixture.matchingRoot; + let rootRequests = 0; + client.setRequestHandler(ListRootsRequestSchema, async () => { + rootRequests += 1; + return { roots: [{ uri: currentRoot }] }; + }); + + const unchangedFixture = await createRuntimeRoutingFixture(); + const unchangedRuntime = await createMiftahRuntime(unchangedFixture.configPath); + const [unchangedClientTransport, unchangedServerTransport] = InMemoryTransport.createLinkedPair(); + const unchangedClient = new Client( + { name: "unadvertised-root-change-client", version: "1.0.0" }, + { capabilities: { roots: {} } } + ); + let unchangedRoot = unchangedFixture.matchingRoot; + let unchangedRootRequests = 0; + unchangedClient.setRequestHandler(ListRootsRequestSchema, async () => { + unchangedRootRequests += 1; + return { roots: [{ uri: unchangedRoot }] }; + }); + + try { + await Promise.all([runtime.connect(serverTransport), client.connect(clientTransport)]); + expect(await client.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + content: [{ type: "text", text: "personal" }] + }); + expect(rootRequests).toBe(1); + + currentRoot = fixture.changedRoot; + await client.notification({ method: "notifications/roots/list_changed" }); + await expect.poll(() => rootRequests).toBe(2); + expect(await client.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + content: [{ type: "text", text: "work" }] + }); + + await Promise.all([ + unchangedRuntime.connect(unchangedServerTransport), + unchangedClient.connect(unchangedClientTransport) + ]); + expect(await unchangedClient.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + content: [{ type: "text", text: "personal" }] + }); + expect(unchangedRootRequests).toBe(1); + + unchangedRoot = unchangedFixture.changedRoot; + const rootsChanged = RootsListChangedNotificationSchema.parse({ + method: "notifications/roots/list_changed" + }); + await unchangedClientTransport.send({ jsonrpc: "2.0", ...rootsChanged }); + expect(await unchangedClient.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + content: [{ type: "text", text: "personal" }] + }); + expect(unchangedRootRequests).toBe(1); + } finally { + await client.close(); + await runtime.close(); + await unchangedClient.close(); + await unchangedRuntime.close(); + await rm(fixture.directory, { recursive: true, force: true }); + await rm(unchangedFixture.directory, { recursive: true, force: true }); + restoreProfile(); + } + }); + + it("refreshes again when roots change during an in-flight roots request", async () => { + const restoreProfile = withoutMiftahProfile(); + const fixture = await createRuntimeRoutingFixture(); + const runtime = await createMiftahRuntime(fixture.configPath); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client( + { name: "overlapping-root-change-client", version: "1.0.0" }, + { capabilities: { roots: { listChanged: true } } } + ); + let firstRequestStarted!: () => void; + const firstRequest = new Promise((resolve) => { + firstRequestStarted = resolve; + }); + let releaseFirstRequest!: () => void; + const firstRequestReleased = new Promise((resolve) => { + releaseFirstRequest = resolve; + }); + let rootRequests = 0; + let currentRoot = fixture.matchingRoot; + client.setRequestHandler(ListRootsRequestSchema, async () => { + rootRequests += 1; + const responseRoot = currentRoot; + if (rootRequests === 1) { + firstRequestStarted(); + await firstRequestReleased; + } + return { roots: [{ uri: responseRoot }] }; + }); + + try { + await Promise.all([runtime.connect(serverTransport), client.connect(clientTransport)]); + await firstRequest; + + currentRoot = fixture.changedRoot; + await client.notification({ method: "notifications/roots/list_changed" }); + releaseFirstRequest(); + await expect.poll(() => rootRequests).toBe(2); + + expect(await client.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + content: [{ type: "text", text: "work" }] + }); + } finally { + await client.close(); + await runtime.close(); + await rm(fixture.directory, { recursive: true, force: true }); + restoreProfile(); + } + }); + + it("uses exactly one routing context snapshot per proxied operation", async () => { + const config = validateConfig({ + version: "1", + name: "accounts", + defaultProfile: "work", + upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, + profiles: { + work: { env: { TEST_ACCOUNT_NAME: "work" } }, + personal: { env: { TEST_ACCOUNT_NAME: "personal" } } + }, + routing: { + rules: [{ name: "context-profile", when: { "context.project": "personal" }, profile: "personal" }] + } + }); + const snapshots: RoutingContextSnapshot[] = [ + { + context: { project: "personal" }, + evidence: { cwd: process.cwd(), fileRoots: [] }, + profileHints: [] + }, + { + context: {}, + evidence: { cwd: process.cwd(), fileRoots: [] }, + profileHints: [ + { + profile: "personal", + source: "environment", + evidence: { kind: "environment", variable: "MIFTAH_PROFILE" } + } + ] + } + ]; + let snapshotsCollected = 0; + const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer( + config, + new ProfileManager(config), + manager, + async () => { + const snapshot = snapshots[snapshotsCollected]; + snapshotsCollected += 1; + if (!snapshot) throw new Error("Unexpected routing context collection"); + return snapshot; + } + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "counting-context-client", version: "1.0.0" }); + + try { + await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]); + expect(snapshotsCollected).toBe(0); + + expect(await client.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + content: [{ type: "text", text: "personal" }] + }); + expect(await client.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + content: [{ type: "text", text: "personal" }] + }); + expect(snapshotsCollected).toBe(2); + } finally { + await client.close(); + await wrapper.close(); + } + }); + + it("returns unknown context profile errors without forwarding the proxied call", async () => { + const originalProfile = process.env.MIFTAH_PROFILE; + process.env.MIFTAH_PROFILE = "missing-profile"; + const callCountPath = join(process.cwd(), ".miftah-routing-context-call-count"); + const fixture = await createRuntimeRoutingFixture({ TEST_CALL_TOOL_COUNT_PATH: callCountPath }); + const runtime = await createMiftahRuntime(fixture.configPath); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "invalid-context-profile-client", version: "1.0.0" }); + + try { + await Promise.all([runtime.connect(serverTransport), client.connect(clientTransport)]); + + expect(await client.callTool({ name: "whoami", arguments: {} })).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("ROUTING_PROFILE_NOT_FOUND") }] + }); + await expect(access(callCountPath)).rejects.toThrow(); + } finally { + await client.close(); + await runtime.close(); + await rm(fixture.directory, { recursive: true, force: true }); + await rm(callCountPath, { force: true }); + if (originalProfile === undefined) delete process.env.MIFTAH_PROFILE; + else process.env.MIFTAH_PROFILE = originalProfile; + } + }); }); diff --git a/tests/operation-pipeline.test.ts b/tests/operation-pipeline.test.ts index 02fd069e..a5c2e7a0 100644 --- a/tests/operation-pipeline.test.ts +++ b/tests/operation-pipeline.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest"; import { validateConfig } from "../src/config/validate-config.js"; import { MiftahServer } from "../src/mcp/server/miftah-server.js"; import { ProfileManager } from "../src/profiles/profile-manager.js"; +import type { RoutingContextSnapshot } from "../src/routing/routing-types.js"; import { MultiUpstreamProcessManager } from "../src/upstream/multi-upstream-process-manager.js"; import { UpstreamProcessManager } from "../src/upstream/upstream-process-manager.js"; @@ -229,6 +230,82 @@ describe("operation pipeline", () => { } }); + it("does not forward destructive calls when project context profile hints conflict", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-operation-context-ambiguous-")); + const toolCountPath = join(directory, "tool-count"); + const auditPath = join(directory, "audit.jsonl"); + const snapshot: RoutingContextSnapshot = { + context: {}, + evidence: { + cwd: join(directory, "project"), + fileRoots: [], + marker: { path: join(directory, "project", ".miftahrc.json") } + }, + profileHints: [ + { + profile: "work", + source: "project-marker", + evidence: { kind: "marker", path: join(directory, "work", ".miftahrc.json") } + }, + { + profile: "personal", + source: "project-marker", + evidence: { kind: "marker", path: join(directory, "personal", ".miftahrc.json") } + } + ] + }; + const config = validateConfig({ + version: "1", + name: "accounts", + defaultProfile: "work", + upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, + profiles: { + work: { + env: { + TEST_ACCOUNT_NAME: "work", + TEST_CALL_TOOL_COUNT_PATH: toolCountPath + } + }, + personal: { + env: { + TEST_ACCOUNT_NAME: "personal", + TEST_CALL_TOOL_COUNT_PATH: toolCountPath + } + } + }, + tooling: { toolRiskOverrides: { create_item: "destructive" } }, + security: { requireExplicitProfileForDestructive: true }, + audit: { path: auditPath } + }); + const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer(config, new ProfileManager(config), manager, async () => snapshot); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }); + + try { + await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]); + + expect(await client.callTool({ name: "create_item", arguments: { name: "x" } })).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("ROUTING_AMBIGUOUS") }] + }); + await expect(access(toolCountPath)).rejects.toThrow(); + + const events = (await readFile(auditPath, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect(events.find((event) => event.kind === "operation" && event.operation === "tools/call")).toMatchObject({ + status: "ambiguous", + errorCode: "ROUTING_AMBIGUOUS", + routingEvidence: snapshot.evidence + }); + } finally { + await client.close(); + await wrapper.close(); + } + }); + it("routes resource reads and prompt retrieval to the selected profile", async () => { const config = validateConfig({ version: "1", diff --git a/tests/routing-context-docs-contract.test.ts b/tests/routing-context-docs-contract.test.ts new file mode 100644 index 00000000..5f8dd64a --- /dev/null +++ b/tests/routing-context-docs-contract.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +function readRepositoryFile(path: string): string { + return readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); +} + +function unreleasedSection(changelog: string): string { + const afterHeading = changelog.split(/^## \[Unreleased\]\s*$/mu)[1]; + if (afterHeading === undefined) throw new Error("CHANGELOG.md must contain an Unreleased section."); + const nextRelease = afterHeading.search(/^## \[/mu); + return nextRelease === -1 ? afterHeading : afterHeading.slice(0, nextRelease); +} + +function routingContextSection(config: string): string { + const afterHeading = config.split(/^## Routing context\s*$/mu)[1]; + if (afterHeading === undefined) throw new Error("docs/config.md must contain a Routing context section."); + const nextSection = afterHeading.search(/^## /mu); + return nextSection === -1 ? afterHeading : afterHeading.slice(0, nextSection); +} + +describe("routing context documentation contract", () => { + it("documents metadata-only selection, roots, evidence, and its security boundary", () => { + const readme = readRepositoryFile("README.md"); + const architecture = readRepositoryFile("docs/architecture.md"); + const config = readRepositoryFile("docs/config.md"); + const security = readRepositoryFile("docs/security.md"); + const changelog = readRepositoryFile("CHANGELOG.md"); + const routingContext = routingContextSection(config); + + expect(readme).toContain("[routing context](docs/config.md#routing-context)"); + expect(config).toContain("## Routing context"); + expect(routingContext).toContain("only runtime configuration authority"); + expect(routingContext).toContain('"profiles": {'); + expect(routingContext).toContain("only top-level key"); + expect(routingContext).toContain("all values are strings"); + expect(routingContext).toContain("does not merge with runtime configuration"); + expect(routingContext).toContain("`MIFTAH_PROFILE`"); + expect(routingContext).toContain("`MIFTAH_PROJECT`"); + expect(routingContext).toContain("environment hint, project-marker hint, configured rule, then fallback"); + expect(routingContext).toContain("The nearest valid project marker wins"); + expect(routingContext).toContain("`notifications/roots/list_changed`"); + expect(routingContext).toContain("does not poll roots"); + expect(routingContext).toContain("`miftah_route_preview`"); + expect(routingContext).toContain("`routingEvidence`"); + expect(architecture).toContain("metadata-only routing context collector"); + expect(architecture).toContain("one immutable snapshot"); + expect(security).toContain("Project markers cannot"); + expect(security).toContain("cannot add credentials"); + expect(security).toContain("never contains the raw `MIFTAH_PROJECT` value"); + expect(unreleasedSection(changelog)).toMatch(/\[#20\][\s\S]*routing context[\s\S]*audit/iu); + }); +}); diff --git a/tests/routing-context.test.ts b/tests/routing-context.test.ts new file mode 100644 index 00000000..5b7ccb42 --- /dev/null +++ b/tests/routing-context.test.ts @@ -0,0 +1,427 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { collectRoutingContext } from "../src/routing/context-collector.js"; + +const execFile = promisify(execFileCallback); +const testDirectories: string[] = []; + +async function createProject(): Promise { + const directory = await mkdtemp(join(process.cwd(), ".routing-context-")); + testDirectories.push(directory); + return directory; +} + +async function writeJson(path: string, value: unknown): Promise { + await writeFile(path, JSON.stringify(value)); +} + +function fileUri(path: string): string { + return pathToFileURL(path).toString(); +} + +afterEach(async () => { + await Promise.all(testDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("routing context collector", () => { + it("collects only the wrapper-specific profile from a strict project marker", async () => { + const root = await createProject(); + await writeJson(join(root, ".miftahrc.json"), { + profiles: { github: "work", other: "personal" } + }); + + const snapshot = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: ["work", "personal"], + cwd: root, + environment: {}, + mcpRoots: [fileUri(root)] + }); + + expect(snapshot.context.marker).toEqual({ profile: "work" }); + expect(snapshot.profileHints).toEqual([ + { profile: "work", source: "project-marker", evidence: { kind: "marker", path: join(root, ".miftahrc.json") } } + ]); + + await writeJson(join(root, ".miftahrc.json"), { + profiles: { github: "work" }, + token: "must-never-be-read" + }); + const rejectedMarker = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: ["work"], + cwd: root, + environment: {}, + mcpRoots: [fileUri(root)] + }); + + expect(rejectedMarker.context.marker).toBeUndefined(); + expect(rejectedMarker.profileHints).toEqual([]); + expect(JSON.stringify(rejectedMarker)).not.toContain("must-never-be-read"); + }); + + it("collects launch environment metadata without disclosing raw project data in evidence", async () => { + const root = await createProject(); + const project = "https://token@example.test/private?secret=value"; + + const snapshot = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: ["work"], + cwd: root, + environment: { MIFTAH_PROFILE: "work", MIFTAH_PROJECT: project }, + mcpRoots: [fileUri(root)] + }); + + expect(snapshot.context.environment).toEqual({ + profile: "work", + project: "https://example.test/private?secret=%5BREDACTED%5D" + }); + expect(snapshot.profileHints).toContainEqual({ + profile: "work", + source: "environment", + evidence: { kind: "environment", variable: "MIFTAH_PROFILE" } + }); + expect(snapshot.evidence.environment).toEqual({ profile: "work", hasProject: true }); + expect(JSON.stringify(snapshot.evidence)).not.toContain(project); + expect(JSON.stringify(snapshot.evidence)).not.toContain("token@example.test"); + }); + + it("normalizes file roots and never probes non-file root URIs", async () => { + const root = await createProject(); + const fileRootWithSensitiveParts = `${fileUri(root)}?token=secret#fragment`; + + const snapshot = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: [], + cwd: root, + environment: {}, + mcpRoots: [ + { uri: fileRootWithSensitiveParts }, + { uri: "https://user:password@example.test/repository?access_token=secret#private" } + ] + }); + + expect(snapshot.context.fileRoots).toEqual([fileUri(root)]); + expect(snapshot.evidence.fileRoots).toEqual([fileUri(root)]); + expect(JSON.stringify(snapshot)).not.toContain("password"); + expect(JSON.stringify(snapshot)).not.toContain("access_token=secret"); + }); + + it("selects nearest package and enclosing monorepo metadata without escaping the file root", async () => { + const root = await createProject(); + const application = join(root, "packages", "app", "src"); + await mkdir(application, { recursive: true }); + await writeJson(join(root, ".miftahrc.json"), { profiles: { github: "work" } }); + await writeJson(join(root, "package.json"), { + name: "@example/monorepo", + repository: "https://token@example.test/monorepo?token=secret", + workspaces: ["packages/*"] + }); + await writeJson(join(root, "packages", "app", "package.json"), { name: "@example/app" }); + + const snapshot = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: ["work"], + cwd: application, + environment: {}, + mcpRoots: [fileUri(root)] + }); + + expect(snapshot.context.cwd).toBe(application); + expect(snapshot.context.marker).toEqual({ profile: "work" }); + expect(snapshot.context.package).toEqual({ name: "@example/app" }); + expect(snapshot.context.workspace).toEqual({ + name: "@example/monorepo", + repository: "https://example.test/monorepo?token=%5BREDACTED%5D" + }); + expect(snapshot.evidence.workspace).toEqual({ + path: join(root, "package.json"), + name: "@example/monorepo", + repository: "https://example.test/monorepo?token=%5BREDACTED%5D" + }); + }); + + it("collects a redacted Git origin and safely treats missing Git metadata as absent", async () => { + const root = await createProject(); + await execFile("git", ["init"], { cwd: root }); + await execFile("git", ["remote", "add", "origin", "https://user:password@example.test/repo?token=secret"], { + cwd: root + }); + + const repository = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: [], + cwd: root, + environment: {}, + mcpRoots: [fileUri(root)] + }); + expect(repository.context.git).toEqual({ + origin: "https://example.test/repo?token=%5BREDACTED%5D" + }); + + const noRepository = await createProject(); + const noGitMetadata = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: [], + cwd: noRepository, + environment: {}, + mcpRoots: [fileUri(noRepository)] + }); + expect(noGitMetadata.context.git).toBeUndefined(); + + const unavailableGit = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: [], + cwd: noRepository, + environment: {}, + mcpRoots: [fileUri(noRepository)], + gitExecutable: "miftah-git-does-not-exist" + }); + expect(unavailableGit.context.git).toBeUndefined(); + expect(unavailableGit.evidence.git).toBeUndefined(); + }); + + it("reads only a local Git origin rather than a global configuration value", async () => { + const root = await createProject(); + const gitHome = await createProject(); + await execFile("git", ["init"], { cwd: root }); + await execFile("git", ["remote", "add", "origin", "https://source.example/repository"], { cwd: root }); + await writeFile( + join(gitHome, ".gitconfig"), + '[url "https://global.example/rewritten"]\n\tinsteadOf = https://source.example/\n' + ); + const originalHome = process.env.HOME; + const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.HOME = gitHome; + process.env.XDG_CONFIG_HOME = join(gitHome, "config"); + + try { + const snapshot = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: [], + cwd: root, + environment: {}, + mcpRoots: [fileUri(root)] + }); + + expect(snapshot.context.git).toEqual({ origin: "https://source.example/repository" }); + expect(snapshot.evidence.git).toEqual({ origin: "https://source.example/repository" }); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalXdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + }); + + it("fails safely when an explicit profile hint is not configured", async () => { + const root = await createProject(); + + await expect( + collectRoutingContext({ + wrapperName: "github", + knownProfileNames: ["work"], + cwd: root, + environment: { MIFTAH_PROFILE: "unknown" }, + mcpRoots: [fileUri(root)] + }) + ).rejects.toMatchObject({ code: "ROUTING_PROFILE_NOT_FOUND" }); + }); + + it("excludes the explicitly supplied runtime config from automatic marker discovery", async () => { + const root = await createProject(); + const runtimeConfigPath = join(root, "miftah.json"); + await writeJson(runtimeConfigPath, { profiles: { github: "work" } }); + + const snapshot = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: ["work"], + cwd: root, + environment: {}, + runtimeConfigPath, + mcpRoots: [fileUri(root)] + }); + + expect(snapshot.context.marker).toBeUndefined(); + expect(snapshot.profileHints).toEqual([]); + }); + + it("excludes a symlinked runtime config from automatic marker discovery", async () => { + const root = await createProject(); + const runtimeConfigPath = join(root, "miftah.json"); + const runtimeConfigLink = join(root, "runtime-config.json"); + await writeJson(runtimeConfigPath, { profiles: { github: "work" } }); + await symlink(runtimeConfigPath, runtimeConfigLink); + + const snapshot = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: ["work"], + cwd: root, + environment: {}, + runtimeConfigPath: runtimeConfigLink, + mcpRoots: [fileUri(root)] + }); + + expect(snapshot.context.marker).toBeUndefined(); + expect(snapshot.profileHints).toEqual([]); + }); + + it("does not follow a marker symlink outside the metadata boundary", async () => { + const root = await createProject(); + const outside = await createProject(); + const externalMarker = join(outside, "marker.json"); + await writeJson(externalMarker, { profiles: { github: "work" } }); + await symlink(externalMarker, join(root, ".miftahrc.json")); + + const snapshot = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: ["work"], + cwd: root, + environment: {}, + mcpRoots: [fileUri(root)] + }); + + expect(snapshot.context.marker).toBeUndefined(); + expect(snapshot.profileHints).toEqual([]); + }); + + it("ignores an irrelevant child marker while searching enclosing metadata", async () => { + const root = await createProject(); + const child = join(root, "packages", "app"); + await mkdir(child, { recursive: true }); + await writeJson(join(root, ".miftahrc.json"), { profiles: { github: "work" } }); + await writeJson(join(child, ".miftahrc.json"), { profiles: { other: "personal" } }); + + const snapshot = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: ["work", "personal"], + cwd: child, + environment: {}, + mcpRoots: [fileUri(root)] + }); + + expect(snapshot.context.marker).toEqual({ profile: "work" }); + expect(snapshot.profileHints).toEqual([ + { profile: "work", source: "project-marker", evidence: { kind: "marker", path: join(root, ".miftahrc.json") } } + ]); + }); + + it("uses the nearest valid marker without combining ancestor profiles", async () => { + const root = await createProject(); + const child = join(root, "packages", "app"); + await mkdir(child, { recursive: true }); + await writeJson(join(root, ".miftahrc.json"), { profiles: { github: "work" } }); + await writeJson(join(child, "miftah.json"), { profiles: { github: "personal" } }); + + const snapshot = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: ["work", "personal"], + cwd: child, + environment: {}, + mcpRoots: [fileUri(root)] + }); + + expect(snapshot.context.marker).toEqual({ profile: "personal" }); + expect(snapshot.profileHints).toEqual([ + { profile: "personal", source: "project-marker", evidence: { kind: "marker", path: join(child, "miftah.json") } } + ]); + }); + + it("exposes no fields outside the explicit marker and package schemas", async () => { + const root = await createProject(); + await writeJson(join(root, ".miftahrc.json"), { + profiles: { github: "work" }, + credentials: { token: "marker-secret" } + }); + await writeJson(join(root, "package.json"), { + name: "@example/package", + repository: { url: "https://example.test/repo" }, + scripts: { build: "echo package-secret" }, + customRouting: "must-not-appear" + }); + + const snapshot = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: ["work"], + cwd: root, + environment: {}, + mcpRoots: [fileUri(root)] + }); + + expect(snapshot.context.package).toEqual({ + name: "@example/package", + repository: "https://example.test/repo" + }); + expect(JSON.stringify(snapshot)).not.toContain("marker-secret"); + expect(JSON.stringify(snapshot)).not.toContain("package-secret"); + expect(JSON.stringify(snapshot)).not.toContain("must-not-appear"); + }); + + it("does not follow a symlinked cwd outside the file-root boundary", async () => { + const root = await createProject(); + const outside = await createProject(); + const outsideApplication = join(outside, "app"); + await mkdir(outsideApplication); + await writeJson(join(outside, ".miftahrc.json"), { profiles: { github: "work" } }); + await writeJson(join(outside, "package.json"), { name: "@outside/package" }); + await symlink(outside, join(root, "linked-project")); + + const snapshot = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: ["work"], + cwd: join(root, "linked-project", "app"), + environment: {}, + mcpRoots: [fileUri(root)] + }); + + expect(snapshot.context.marker).toBeUndefined(); + expect(snapshot.context.package).toBeUndefined(); + expect(snapshot.profileHints).toEqual([]); + }); + + it("does not honor inherited Git path overrides outside the file-root boundary", async () => { + const root = await createProject(); + const outside = await createProject(); + await execFile("git", ["init"], { cwd: outside }); + await execFile("git", ["remote", "add", "origin", "https://example.test/outside"], { cwd: outside }); + const originalGitDirectory = process.env.GIT_DIR; + process.env.GIT_DIR = join(outside, ".git"); + + try { + const snapshot = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: [], + cwd: root, + environment: {}, + mcpRoots: [fileUri(root)] + }); + expect(snapshot.context.git).toBeUndefined(); + } finally { + if (originalGitDirectory === undefined) delete process.env.GIT_DIR; + else process.env.GIT_DIR = originalGitDirectory; + } + }); + + it("does not follow a Git directory indirection outside the file-root boundary", async () => { + const root = await createProject(); + const outside = await createProject(); + await execFile("git", ["init"], { cwd: outside }); + await execFile("git", ["remote", "add", "origin", "https://example.test/outside"], { cwd: outside }); + await writeFile(join(root, ".git"), `gitdir: ${join(outside, ".git")}\n`); + + const snapshot = await collectRoutingContext({ + wrapperName: "github", + knownProfileNames: [], + cwd: root, + environment: {}, + mcpRoots: [fileUri(root)] + }); + + expect(snapshot.context.git).toBeUndefined(); + expect(snapshot.evidence.git).toBeUndefined(); + }); +}); diff --git a/tests/routing-policy.test.ts b/tests/routing-policy.test.ts index 0eb78585..98147686 100644 --- a/tests/routing-policy.test.ts +++ b/tests/routing-policy.test.ts @@ -25,6 +25,150 @@ describe("routing and policy", () => { }); }); + it("matches exact context values against members of a context array", () => { + const engine = new RoutingEngine( + { + rules: [ + { + name: "workspace-root", + when: { "context.fileRoots": "file:///workspace/project" }, + profile: "workspace" + } + ] + }, + "active" + ); + + expect( + engine.resolve({ + toolName: "search_issues", + context: { fileRoots: ["file:///other", "file:///workspace/project"] } + }) + ).toEqual({ profile: "workspace", reason: "rule:workspace-root" }); + }); + + it("matches wildcard context values against members of a context array", () => { + const engine = new RoutingEngine( + { + rules: [ + { + name: "workspace-root", + when: { "context.fileRoots": "file:///workspace/*" }, + profile: "workspace" + } + ] + }, + "active" + ); + + expect( + engine.resolve({ + toolName: "search_issues", + context: { fileRoots: ["file:///other", "file:///workspace/project"] } + }) + ).toEqual({ profile: "workspace", reason: "rule:workspace-root" }); + expect( + engine.resolve({ + toolName: "search_issues", + context: { fileRoots: ["file:///other", "file:///different/project"] } + }) + ).toEqual({ profile: "active", reason: "active-profile" }); + }); + + it("prefers an environment profile hint over marker hints, rules, and active fallback", () => { + const engine = new RoutingEngine( + { + rules: [{ name: "matching-rule", when: { "args.repo": "org/work" }, profile: "rule" }] + }, + "active" + ); + + const decision = engine.resolve({ + toolName: "search_issues", + args: { repo: "org/work" }, + profileHints: [ + { + profile: "marker", + source: "project-marker", + evidence: { kind: "marker", path: "/workspace/.miftah-profile" } + }, + { + profile: "environment", + source: "environment", + evidence: { kind: "environment", variable: "MIFTAH_PROFILE" } + } + ] + }); + + expect(decision).toEqual({ profile: "environment", reason: "profile-hint:environment" }); + expect(decision.reason.startsWith("rule:")).toBe(false); + }); + + it("prefers one project-marker profile hint over matching rules and fallback", () => { + const engine = new RoutingEngine( + { + rules: [{ name: "matching-rule", when: { "args.repo": "org/work" }, profile: "rule" }] + }, + "active" + ); + + expect( + engine.resolve({ + toolName: "search_issues", + args: { repo: "org/work" }, + profileHints: [ + { + profile: "marker", + source: "project-marker", + evidence: { kind: "marker", path: "/workspace/.miftah-profile" } + }, + { + profile: "marker", + source: "project-marker", + evidence: { kind: "marker", path: "/workspace/nested/.miftah-profile" } + } + ] + }) + ).toEqual({ profile: "marker", reason: "profile-hint:project-marker" }); + }); + + it("rejects distinct project-marker profile hints in stable profile order", () => { + const engine = new RoutingEngine({}, "active"); + const input = { + toolName: "search_issues", + profileHints: [ + { + profile: "zeta", + source: "project-marker" as const, + evidence: { kind: "marker" as const, path: "/workspace/zeta/.miftah-profile" } + }, + { + profile: "alpha", + source: "project-marker" as const, + evidence: { kind: "marker" as const, path: "/workspace/alpha/.miftah-profile" } + } + ] + }; + + expect(() => engine.resolve(input)).toThrow( + "ROUTING_AMBIGUOUS: project-marker profile hints are alpha, zeta" + ); + }); + + it("uses matching rules before fallback when no profile hint is provided", () => { + const engine = new RoutingEngine( + { + rules: [{ name: "matching-rule", when: { "args.repo": "org/work" }, profile: "rule" }] + }, + "active" + ); + + expect(engine.resolve({ toolName: "search_issues", args: { repo: "org/work" } })).toEqual({ + profile: "rule", + reason: "rule:matching-rule" + }); + }); + it("returns an ambiguity error when ask fallback has multiple matches", () => { const engine = new RoutingEngine( { diff --git a/vitest.config.ts b/vitest.config.ts index cc738811..eee23f8f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,8 @@ export default defineConfig({ include: ["tests/**/*.test.ts"], restoreMocks: true, clearMocks: true, + // Real upstream fixtures have one-second startup limits; run files serially to prevent contention. + fileParallelism: false, coverage: { provider: "v8", include: [