diff --git a/README.md b/README.md index 54db8982..9d6e5eba 100644 --- a/README.md +++ b/README.md @@ -102,9 +102,11 @@ Profiles are named credential environments. Keep secret values outside JSON when The GitHub preset pins `ghcr.io/github/github-mcp-server:v1.5.0`. To upgrade safely, read the release notes first, update the tag in your config, run `miftah validate`, then smoke-test both profiles before rollout. -Claude can call `miftah_list_profiles`, `miftah_current_profile`, `miftah_use_profile`, `miftah_profile_info`, `miftah_health`, `miftah_validate_config`, `miftah_list_upstream_tools`, `miftah_restart_profile`, and `miftah_route_preview`. Upstream tools are exposed unchanged unless they collide with a reserved management name. After a profile change or active-profile restart, MCP clients receive `tools/list_changed` and should re-list tools before calling a changed schema. +Claude can call `miftah_list_profiles`, `miftah_current_profile`, `miftah_use_profile`, `miftah_profile_info`, `miftah_health`, `miftah_validate_config`, `miftah_list_upstream_tools`, `miftah_restart_profile`, and `miftah_route_preview`. Upstream tools are exposed unchanged unless they collide with a reserved management name. After a profile change or active-profile restart, MCP clients receive list-change notifications and should re-list tools, resources, and prompts before relying on cached capabilities. -For account bundles, define `upstreams` instead of `upstream`. Tools are exposed as `__` (for example `github__search_issues`) and each profile can provide per-upstream environment or header overrides. Resources and prompts are available only when the bundle has exactly one upstream; multi-entry bundles intentionally omit them until namespaced aggregation exists. `miftah_health` reports this availability. See `examples/multi-upstream.miftah.json`. +For account bundles, define `upstreams` instead of `upstream`. Tools, resource names, and prompt names are exposed as `__` (for example `github__search_issues` and `github__account_prompt`), and each profile can provide per-upstream environment or header overrides. A multi-upstream resource URI is a Miftah-owned route such as `miftah://resource/github?uri=account%3A%2F%2Fcurrent`; its encoded value is redacted before exposure, while Miftah retains the exact original URI only in its per-profile route map. Before exposing multi-upstream resource or prompt URI metadata, Miftah strips URI userinfo and fragments and redacts every query value. Reads and prompt gets are routed only through the exact route map, never by guessing from a client identifier. + +Multi-upstream resource and prompt lists use opaque, bounded in-memory Miftah cursors so individual upstream cursors are not exposed. Re-list after a profile change or restart instead of retaining an old cursor. A standard `upstream` or a one-entry `upstreams` map preserves raw resource URIs, prompt names, and native upstream cursors for compatibility. A zero-entry bundle still omits resource and prompt capabilities. Aggregate discovery currently fails closed when any configured upstream cannot list its capability; partial healthy-upstream discovery is deferred until the resilience work is complete. See `examples/multi-upstream.miftah.json`. ## Routing and safety @@ -163,7 +165,7 @@ Use `miftah doctor` to inspect config and upstream readiness without printing pr ## Current boundaries -The current experimental code implements local STDIO and remote HTTP/SSE upstream clients, profile switching, hybrid routing rules, policies, namespaced tools for account bundles, resources/prompts proxying for a standard or sole named upstream, in-memory process/session caching, redacted JSONL audit logging, and a packageable CLI. Resources and prompts are intentionally unavailable for zero- or multi-entry `upstreams` bundles. Unsupported lifecycle tuning, persisted state, UI, routing plugins, profile matchers, and configurable tool namespaces are rejected with `UNSUPPORTED_CONFIG_OPTION` rather than silently ignored. +The current experimental code implements local STDIO and remote HTTP/SSE upstream clients, profile switching, hybrid routing rules, policies, namespaced tools/resources/prompts for account bundles, in-memory process/session caching, redacted JSONL audit logging, and a packageable CLI. Multi-upstream resource and prompt discovery currently fails closed rather than returning a partial aggregate. Unsupported lifecycle tuning, persisted state, UI, routing plugins, profile matchers, and configurable tool namespaces are rejected with `UNSUPPORTED_CONFIG_OPTION` rather than silently ignored. ## License diff --git a/docs/architecture.md b/docs/architecture.md index 26dc7b60..eb85e90a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,11 @@ MCP client The public server is built with the official `@modelcontextprotocol/sdk` `Server` and `StdioServerTransport`. Each profile gets an SDK `Client` and `StdioClientTransport` on first use. A single-flight start map prevents concurrent calls from launching duplicate processes. Tool discovery is held in immutable, per-profile capability snapshots; concurrent callers share one discovery operation, and a snapshot is published only after all upstream discovery and collision checks complete. A call captures its active profile before discovery, so a later profile switch cannot change that call's active-profile fallback. -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 or an active-profile restart when a client must re-list. 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`. A standard `upstream` and a named `upstreams` map with exactly one entry proxy resources and prompts through that sole upstream. A zero-entry map and a map with two or more entries omit the MCP `resources` and `prompts` capabilities and do not register their handlers; direct resource or prompt requests then receive the standard `-32601` method-not-found response. This prevents Miftah from accidentally selecting the first upstream when aggregation and namespacing are unavailable. `miftah_health` reports `resourcePromptProxy` availability and its reason, which is also included in the server instructions when disabled. Reserved management tool names are protected; the default collision strategy prefixes a conflicting upstream name with `upstream_`. +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 or an active-profile restart when a client must re-list. 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`. + +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. A read or prompt get first resolves that route, then forwards to precisely that 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. + +The server advertises `resources.listChanged` and `prompts.listChanged` with `tools.listChanged`, and emits all applicable notifications after a profile change or active-profile restart. Clients must re-list because route maps and aggregate cursors are profile-local and invalidated on profile changes and restarts. A standard `upstream` and a named `upstreams` map with exactly one entry proxy resources and prompts through that sole upstream without changing their URIs, names, or cursors. A zero-entry map omits the MCP `resources` and `prompts` capabilities and does not register their handlers; direct resource or prompt requests then receive the standard `-32601` method-not-found response. Multi-upstream discovery is deliberately fail-closed: if any configured upstream cannot list a capability, Miftah returns no partial aggregate. `miftah_health` reports capability availability and its reason when disabled. Reserved management tool names are protected; the default collision strategy prefixes a conflicting upstream name with `upstream_`. Configuration and runtime concerns are intentionally separate: diff --git a/docs/config.md b/docs/config.md index d0a98724..0dab4b92 100644 --- a/docs/config.md +++ b/docs/config.md @@ -12,7 +12,11 @@ Miftah accepts config version `"1"` only and does not silently migrate config fi The generated JSON Schema enforces static structure, including exactly one of `upstream` or `upstreams`. References to names declared in dynamic maps cannot be represented by JSON Schema alone; run `miftah validate` in addition to editor validation to verify profile, policy, routing, lock, and per-profile upstream references. -With `upstreams`, each profile may override `env`, `headers`, `args`, or `cwd` under a named upstream. Miftah namespaces discovered tools as `__` so one wrapper can safely expose several providers. Tool discovery uses the active profile. Clients receive `notifications/tools/list_changed` after a profile change or active-profile restart and must re-run `tools/list` before relying on a changed tool set or schema. If a routing rule selects another profile, Miftah forwards only tools with an identical client-visible schema in both profiles; otherwise it returns `TOOL_SCHEMA_MISMATCH` instead of forwarding a call whose schema the client did not see. Resources and prompts are proxied only when `upstreams` contains exactly one entry; zero-entry and multi-entry bundles omit those capabilities and their handlers rather than choosing an upstream. Use tools or a wrapper with a single upstream for resources and prompts while namespaced aggregation is deferred. +With `upstreams`, each profile may override `env`, `headers`, `args`, or `cwd` under a named upstream. Miftah namespaces discovered tools as `__` so one wrapper can safely expose several providers. Tool discovery uses the active profile. Clients receive `notifications/tools/list_changed` after a profile change or active-profile restart and must re-run `tools/list` before relying on a changed tool set or schema. If a routing rule selects another profile, Miftah forwards only tools with an identical client-visible schema in both profiles; otherwise it returns `TOOL_SCHEMA_MISMATCH` instead of forwarding a call whose schema the client did not see. + +For a multi-entry `upstreams` map, Miftah aggregates resources and prompts. Resource names and prompt names use `__`. Resource URIs use `miftah://resource/?uri=` and resolve through an exact, per-profile route map; callers cannot select an upstream by supplying a raw URI or an unlisted namespaced identifier. Prompt links and sub-resource URIs become exact Miftah routes to their originating upstream as well. Before publication, Miftah strips URI userinfo/fragments and redacts every query value in resource and prompt URI metadata. Multi-upstream list cursors are opaque, bounded Miftah cursors, scoped to the active profile and capability type, and cannot be reused after a profile change or restart. Clients receive `notifications/resources/list_changed` and `notifications/prompts/list_changed` with the tool notification and must re-list all affected capabilities. + +A standard `upstream` and a one-entry `upstreams` map retain raw resource URIs, prompt names, and native upstream pagination. A zero-entry map omits resource and prompt capabilities. Multi-upstream discovery fails closed when any configured upstream fails to list resources or prompts; Miftah does not currently return a partial aggregate from healthy upstreams. Before loading secret sources or starting an upstream, Miftah validates that `defaultProfile`, profile policy names, routing-rule profiles, `security.lockToProfile`, and per-profile upstream override names all exist. Validation errors are human-readable and expose `MiftahError.details.diagnostics` for programs; each diagnostic includes a stable `code`, dotted `path`, `severity`, `message`, and remediation. diff --git a/src/mcp/server/miftah-server.ts b/src/mcp/server/miftah-server.ts index 17757b9c..66f376fd 100644 --- a/src/mcp/server/miftah-server.ts +++ b/src/mcp/server/miftah-server.ts @@ -8,6 +8,8 @@ import { ListToolsRequestSchema, ReadResourceRequestSchema, type CallToolResult, + type GetPromptRequest, + type ReadResourceRequest, type Tool } from "@modelcontextprotocol/sdk/types.js"; import type { MiftahConfig } from "../../config/types.js"; @@ -20,6 +22,7 @@ import { UpstreamProcessManager } from "../../upstream/upstream-process-manager. import { MultiUpstreamProcessManager } from "../../upstream/multi-upstream-process-manager.js"; import type { UpstreamSession } from "../../upstream/upstream-session.js"; import { MiftahError } from "../../utils/errors.js"; +import { ResourcePromptRegistry } from "./resource-prompt-registry.js"; import { ToolRegistry, type ToolSnapshot } from "./tool-registry.js"; const managementTools: Tool[] = [ @@ -73,6 +76,7 @@ export class MiftahServer { private readonly audit?: AuditLogger; private readonly resourcePromptProxy: ResourcePromptProxyAvailability; private readonly toolRegistry: ToolRegistry; + private readonly resourcePromptRegistry?: ResourcePromptRegistry; constructor( private readonly config: MiftahConfig, @@ -83,10 +87,16 @@ export class MiftahServer { this.server = new Server( { name: `miftah-${config.name}`, version: "0.1.1" }, { - debouncedNotificationMethods: ["notifications/tools/list_changed"], + debouncedNotificationMethods: [ + "notifications/tools/list_changed", + "notifications/resources/list_changed", + "notifications/prompts/list_changed" + ], capabilities: { tools: { listChanged: true }, - ...(this.resourcePromptProxy.available ? { resources: {}, prompts: {} } : {}) + ...(this.resourcePromptProxy.available + ? { resources: { listChanged: true }, prompts: { listChanged: true } } + : {}) }, instructions: [ "Miftah wraps an upstream MCP and routes requests through local credential profiles.", @@ -108,6 +118,17 @@ export class MiftahServer { ), (name, upstreamName) => this.exposedToolName(name, upstreamName) ); + if (this.upstreams instanceof MultiUpstreamProcessManager && this.upstreams.listUpstreams().length > 1) { + const multiUpstreams = this.upstreams; + this.resourcePromptRegistry = new ResourcePromptRegistry( + () => multiUpstreams.listUpstreams(), + (profile, upstreamName, params) => + this.callUpstream(profile, upstreamName, (session) => session.listResources(params)), + (profile, upstreamName, params) => + this.callUpstream(profile, upstreamName, (session) => session.listPrompts(params)), + (value) => redactSecrets(value, this.upstreams.getSecretValues()) + ); + } if (config.audit?.enabled !== false && config.audit?.path) { this.audit = new AuditLogger(config.audit.path, { includeArguments: config.audit.includeArguments, @@ -141,20 +162,28 @@ export class MiftahServer { if (this.resourcePromptProxy.available) { const upstreamName = this.resourcePromptProxy.upstreamName; - this.server.setRequestHandler(ListResourcesRequestSchema, async () => { - return this.proxyResourcePrompt(upstreamName, (session) => session.listResources()); + this.server.setRequestHandler(ListResourcesRequestSchema, async (request) => { + const profile = this.profiles.current().activeProfile; + if (this.resourcePromptRegistry) return this.resourcePromptRegistry.listResources(profile, request.params?.cursor); + return this.proxyResourcePrompt(profile, upstreamName, (session) => session.listResources(request.params)); }); this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { - return this.proxyResourcePrompt(upstreamName, (session) => session.readResource(request.params)); + const profile = this.profiles.current().activeProfile; + if (this.resourcePromptRegistry) return this.readAggregatedResource(profile, request.params); + return this.proxyResourcePrompt(profile, upstreamName, (session) => session.readResource(request.params)); }); - this.server.setRequestHandler(ListPromptsRequestSchema, async () => { - return this.proxyResourcePrompt(upstreamName, (session) => session.listPrompts()); + this.server.setRequestHandler(ListPromptsRequestSchema, async (request) => { + const profile = this.profiles.current().activeProfile; + if (this.resourcePromptRegistry) return this.resourcePromptRegistry.listPrompts(profile, request.params?.cursor); + return this.proxyResourcePrompt(profile, upstreamName, (session) => session.listPrompts(request.params)); }); this.server.setRequestHandler(GetPromptRequestSchema, async (request) => { - return this.proxyResourcePrompt(upstreamName, (session) => session.getPrompt(request.params)); + const profile = this.profiles.current().activeProfile; + if (this.resourcePromptRegistry) return this.getAggregatedPrompt(profile, request.params); + return this.proxyResourcePrompt(profile, upstreamName, (session) => session.getPrompt(request.params)); }); } } @@ -269,14 +298,18 @@ export class MiftahServer { const profile = requiredString(args, "profile"); const switched = this.profiles.switch(profile); this.routing.setActiveProfile(switched.activeProfile); + this.invalidateResourcePromptProfiles(switched.previousProfile, switched.activeProfile); await this.notifyToolListChanged(previousSnapshot, this.toolRegistry.peek(switched.activeProfile)); + await this.notifyResourcePromptListChanged(); return textResult(`Active profile changed from ${switched.previousProfile} to ${switched.activeProfile}.`); } if (name === "miftah_reset_profile") { const previousSnapshot = this.toolRegistry.peek(this.profiles.current().activeProfile); const reset = this.profiles.reset(); this.routing.setActiveProfile(reset.activeProfile); + this.invalidateResourcePromptProfiles(reset.previousProfile, reset.activeProfile); await this.notifyToolListChanged(previousSnapshot, this.toolRegistry.peek(reset.activeProfile)); + await this.notifyResourcePromptListChanged(); return textResult(`Active profile reset from ${reset.previousProfile} to ${reset.activeProfile}.`); } if (name === "miftah_profile_info") return textResult(JSON.stringify(this.profiles.info(requiredString(args, "profile")))); @@ -308,8 +341,10 @@ export class MiftahServer { } } finally { this.toolRegistry.invalidate(profile); + this.resourcePromptRegistry?.invalidate(profile); if (profile === this.profiles.current().activeProfile) { await this.notifyToolListChanged(undefined, undefined); + await this.notifyResourcePromptListChanged(); } } return textResult("Profile restarted."); @@ -352,19 +387,25 @@ export class MiftahServer { if (upstreamNames.length === 0) { return { available: false, reason: "No upstream is configured, so resource and prompt proxying is unavailable." }; } - return { - available: false, - reason: "Resource and prompt proxying is unavailable for multi-upstream bundles until namespaced aggregation is available." - }; + return { available: true }; } private async proxyResourcePrompt( + profile: string, + upstreamName: string | undefined, + operation: (session: UpstreamSession) => Promise + ): Promise { + return redactSecrets(await this.callUpstream(profile, upstreamName, operation), this.upstreams.getSecretValues()); + } + + private async callUpstream( + profile: string, upstreamName: string | undefined, operation: (session: UpstreamSession) => Promise ): Promise { try { - const session = await this.upstreams.get(this.profiles.current().activeProfile, upstreamName); - return redactSecrets(await operation(session), this.upstreams.getSecretValues()); + const session = await this.upstreams.get(profile, upstreamName); + return await operation(session); } catch (error) { const safeMessage = redactSecrets( error instanceof Error ? error.message : String(error), @@ -374,6 +415,50 @@ export class MiftahServer { } } + private async readAggregatedResource(profile: string, params: ReadResourceRequest["params"]) { + if (!this.resourcePromptRegistry) throw new Error("Resource aggregation is unavailable"); + const epoch = this.resourcePromptRegistry.captureEpoch(profile); + let route = this.resourcePromptRegistry.resolveResource(profile, params.uri); + if (!route) { + await this.resourcePromptRegistry.listResources(profile); + this.resourcePromptRegistry.assertResourceEpoch(profile, epoch); + route = this.resourcePromptRegistry.resolveResource(profile, params.uri); + } + if (!route) { + throw new MiftahError( + "RESOURCE_NOT_FOUND", + `RESOURCE_NOT_FOUND: resource '${params.uri}' is not exposed for profile '${profile}'` + ); + } + this.resourcePromptRegistry.assertResourceEpoch(profile, epoch); + const result = await this.callUpstream(profile, route.upstreamName, (session) => + session.readResource({ ...params, uri: route.originalUri }) + ); + return this.resourcePromptRegistry.redactReadResult(route, result, epoch); + } + + private async getAggregatedPrompt(profile: string, params: GetPromptRequest["params"]) { + if (!this.resourcePromptRegistry) throw new Error("Prompt aggregation is unavailable"); + const epoch = this.resourcePromptRegistry.captureEpoch(profile); + let route = this.resourcePromptRegistry.resolvePrompt(profile, params.name); + if (!route) { + await this.resourcePromptRegistry.listPrompts(profile); + this.resourcePromptRegistry.assertPromptEpoch(profile, epoch); + route = this.resourcePromptRegistry.resolvePrompt(profile, params.name); + } + if (!route) { + throw new MiftahError( + "PROMPT_NOT_FOUND", + `PROMPT_NOT_FOUND: prompt '${params.name}' is not exposed for profile '${profile}'` + ); + } + this.resourcePromptRegistry.assertPromptEpoch(profile, epoch); + const result = await this.callUpstream(profile, route.upstreamName, (session) => + session.getPrompt({ ...params, name: route.originalName }) + ); + return this.resourcePromptRegistry.redactPromptResult(route, result, epoch); + } + private async writeAudit(event: Parameters[0]): Promise { if (this.audit) await this.audit.log(redactSecrets(event, this.upstreams.getSecretValues())); } @@ -394,6 +479,18 @@ export class MiftahServer { await this.server.sendToolListChanged(); } } + + private async notifyResourcePromptListChanged(): Promise { + if (this.resourcePromptProxy.available && this.server.transport) { + await Promise.all([this.server.sendResourceListChanged(), this.server.sendPromptListChanged()]); + } + } + + private invalidateResourcePromptProfiles(...profiles: string[]): void { + for (const profile of new Set(profiles)) { + this.resourcePromptRegistry?.invalidate(profile); + } + } } function requiredString(args: Record, key: string): string { diff --git a/src/mcp/server/resource-prompt-registry.ts b/src/mcp/server/resource-prompt-registry.ts new file mode 100644 index 00000000..dd1b3726 --- /dev/null +++ b/src/mcp/server/resource-prompt-registry.ts @@ -0,0 +1,339 @@ +import { randomUUID } from "node:crypto"; +import type { + GetPromptResult, + ListPromptsRequest, + ListPromptsResult, + ListResourcesRequest, + ListResourcesResult, + Prompt, + ReadResourceResult, + Resource +} from "@modelcontextprotocol/sdk/types.js"; +import { redactUri } from "../../secrets/redact.js"; +import { MiftahError } from "../../utils/errors.js"; + +export interface ResourceRoute { + readonly profile: string; + readonly upstreamName: string; + readonly originalUri: string; + readonly exposedUri: string; + readonly exposedName?: string; +} + +export interface PromptRoute { + readonly profile: string; + readonly upstreamName: string; + readonly originalName: string; + readonly exposedName: string; +} + +type ResourceDiscovery = ( + profile: string, + upstreamName: string, + params?: ListResourcesRequest["params"] +) => Promise; +type PromptDiscovery = ( + profile: string, + upstreamName: string, + params?: ListPromptsRequest["params"] +) => Promise; +type Redact = (value: T) => T; +type CursorKind = "resources" | "prompts"; +const defaultMaximumCursors = 256; + +interface CursorState { + readonly kind: CursorKind; + readonly profile: string; + readonly upstreamCursors: ReadonlyMap; +} + +/** + * Holds exact per-profile routes for multi-upstream resource and prompt capabilities. + */ +export class ResourcePromptRegistry { + private readonly resourceRoutes = new Map>(); + private readonly promptRoutes = new Map>(); + private readonly cursors = new Map(); + private readonly epochs = new Map(); + + constructor( + private readonly upstreamNames: () => string[], + private readonly discoverResources: ResourceDiscovery, + private readonly discoverPrompts: PromptDiscovery, + private readonly redact: Redact, + private readonly maximumCursors = defaultMaximumCursors + ) {} + + async listResources(profile: string, cursor?: string): Promise { + const epoch = this.captureEpoch(profile); + const upstreamCursors = this.resolveCursor("resources", profile, cursor); + const discovered = await Promise.all( + [...upstreamCursors].map(async ([upstreamName, upstreamCursor]) => ({ + upstreamName, + result: await this.discoverResources(profile, upstreamName, upstreamCursor ? { cursor: upstreamCursor } : undefined) + })) + ); + this.assertCurrentEpoch("resources", profile, epoch); + const routes = new Map(this.resourceRoutes.get(profile)); + const names = new Map(); + for (const route of routes.values()) { + if (route.exposedName !== undefined) names.set(route.exposedName, route.exposedUri); + } + const resources: Resource[] = []; + + for (const { upstreamName, result } of discovered) { + for (const original of result.resources) { + const resource = redactResource(this.redact(original)); + const exposedUri = namespaceResourceUri(upstreamName, redactUri(resource.uri)); + const exposedName = namespaceName(upstreamName, resource.name); + const existing = routes.get(exposedUri); + const nameRoute = names.get(exposedName); + if ( + (existing && + (existing.upstreamName !== upstreamName || + existing.originalUri !== original.uri || + (existing.exposedName !== undefined && existing.exposedName !== exposedName))) || + (nameRoute !== undefined && nameRoute !== exposedUri) + ) { + throw new MiftahError( + "RESOURCE_COLLISION", + `RESOURCE_COLLISION: multiple upstream resources resolve to '${exposedName}' for profile '${profile}'` + ); + } + if (!existing || existing.exposedName === undefined) { + routes.set(exposedUri, { + profile, + upstreamName, + originalUri: original.uri, + exposedUri, + exposedName + }); + names.set(exposedName, exposedUri); + } + resources.push({ ...resource, uri: exposedUri, name: exposedName }); + } + } + + this.resourceRoutes.set(profile, routes); + const nextCursor = this.storeCursor( + "resources", + profile, + new Map( + discovered.flatMap(({ upstreamName, result }) => + result.nextCursor === undefined ? [] : [[upstreamName, result.nextCursor] as const] + ) + ) + ); + return { resources, ...(nextCursor ? { nextCursor } : {}) }; + } + + async listPrompts(profile: string, cursor?: string): Promise { + const epoch = this.captureEpoch(profile); + const upstreamCursors = this.resolveCursor("prompts", profile, cursor); + const discovered = await Promise.all( + [...upstreamCursors].map(async ([upstreamName, upstreamCursor]) => ({ + upstreamName, + result: await this.discoverPrompts(profile, upstreamName, upstreamCursor ? { cursor: upstreamCursor } : undefined) + })) + ); + this.assertCurrentEpoch("prompts", profile, epoch); + const routes = new Map(this.promptRoutes.get(profile)); + const prompts: Prompt[] = []; + + for (const { upstreamName, result } of discovered) { + for (const original of result.prompts) { + const prompt = redactPrompt(this.redact(original)); + const exposedName = namespaceName(upstreamName, prompt.name); + const existing = routes.get(exposedName); + if ( + existing && + (existing.upstreamName !== upstreamName || existing.originalName !== original.name) + ) { + throw new MiftahError( + "PROMPT_COLLISION", + `PROMPT_COLLISION: multiple upstream prompts resolve to '${exposedName}' for profile '${profile}'` + ); + } + if (!existing) { + routes.set(exposedName, { + profile, + upstreamName, + originalName: original.name, + exposedName + }); + } + prompts.push({ ...prompt, name: exposedName }); + } + } + + this.promptRoutes.set(profile, routes); + const nextCursor = this.storeCursor( + "prompts", + profile, + new Map( + discovered.flatMap(({ upstreamName, result }) => + result.nextCursor === undefined ? [] : [[upstreamName, result.nextCursor] as const] + ) + ) + ); + return { prompts, ...(nextCursor ? { nextCursor } : {}) }; + } + + resolveResource(profile: string, exposedUri: string): ResourceRoute | undefined { + return this.resourceRoutes.get(profile)?.get(exposedUri); + } + + resolvePrompt(profile: string, exposedName: string): PromptRoute | undefined { + return this.promptRoutes.get(profile)?.get(exposedName); + } + + captureEpoch(profile: string): number { + return this.epochs.get(profile) ?? 0; + } + + assertResourceEpoch(profile: string, epoch: number): void { + this.assertCurrentEpoch("resources", profile, epoch); + } + + assertPromptEpoch(profile: string, epoch: number): void { + this.assertCurrentEpoch("prompts", profile, epoch); + } + + redactReadResult(route: ResourceRoute, result: ReadResourceResult, epoch: number): ReadResourceResult { + this.assertResourceEpoch(route.profile, epoch); + const redacted = this.redact(result); + return { + ...redacted, + contents: redacted.contents.map((content, index) => + result.contents[index]?.uri === route.originalUri + ? { ...content, uri: route.exposedUri } + : { + ...content, + uri: this.registerLinkedResource(route.profile, route.upstreamName, result.contents[index]?.uri ?? content.uri) + .exposedUri + } + ) + }; + } + + redactPromptResult(route: PromptRoute, result: GetPromptResult, epoch: number): GetPromptResult { + this.assertPromptEpoch(route.profile, epoch); + const redacted = this.redact(result); + return { + ...redacted, + messages: redacted.messages.map((message, index) => { + const originalContent = result.messages[index]?.content; + if (message.content.type === "resource_link") { + const originalUri = originalContent?.type === "resource_link" ? originalContent.uri : message.content.uri; + return { + ...message, + content: { + ...message.content, + uri: this.registerLinkedResource(route.profile, route.upstreamName, originalUri).exposedUri, + icons: redactIconSources(message.content.icons) + } + }; + } + if (message.content.type === "resource") { + const originalUri = originalContent?.type === "resource" ? originalContent.resource.uri : message.content.resource.uri; + return { + ...message, + content: { + ...message.content, + resource: { + ...message.content.resource, + uri: this.registerLinkedResource(route.profile, route.upstreamName, originalUri).exposedUri + } + } + }; + } + return message; + }) + }; + } + + invalidate(profile: string): void { + this.epochs.set(profile, this.captureEpoch(profile) + 1); + this.resourceRoutes.delete(profile); + this.promptRoutes.delete(profile); + for (const [cursor, state] of this.cursors) { + if (state.profile === profile) this.cursors.delete(cursor); + } + } + + private resolveCursor(kind: CursorKind, profile: string, cursor?: string): Map { + if (cursor === undefined) { + return new Map([...this.upstreamNames()].sort().map((upstreamName) => [upstreamName, undefined])); + } + const state = this.cursors.get(cursor); + if (!state || state.kind !== kind || state.profile !== profile) { + const code = kind === "resources" ? "RESOURCE_CURSOR_INVALID" : "PROMPT_CURSOR_INVALID"; + throw new MiftahError(code, `${code}: cursor is not valid for profile '${profile}'`); + } + this.cursors.delete(cursor); + this.cursors.set(cursor, state); + return new Map(state.upstreamCursors); + } + + private storeCursor(kind: CursorKind, profile: string, upstreamCursors: Map): string | undefined { + if (upstreamCursors.size === 0) return undefined; + const cursor = `miftah-${kind}-${randomUUID()}`; + while (this.cursors.size >= this.maximumCursors) { + const oldest = this.cursors.keys().next().value; + if (oldest === undefined) break; + this.cursors.delete(oldest); + } + this.cursors.set(cursor, { kind, profile, upstreamCursors }); + return cursor; + } + + private assertCurrentEpoch(kind: CursorKind, profile: string, epoch: number): void { + if (this.captureEpoch(profile) === epoch) return; + const code = kind === "resources" ? "RESOURCE_DISCOVERY_INVALIDATED" : "PROMPT_DISCOVERY_INVALIDATED"; + throw new MiftahError(code, `${code}: capability discovery was invalidated for profile '${profile}'; re-list it`); + } + + private registerLinkedResource(profile: string, upstreamName: string, originalUri: string): ResourceRoute { + const exposedUri = namespaceResourceUri(upstreamName, redactUri(this.redact(originalUri))); + const routes = new Map(this.resourceRoutes.get(profile)); + const existing = routes.get(exposedUri); + if (existing) { + if (existing.upstreamName === upstreamName && existing.originalUri === originalUri) return existing; + throw new MiftahError( + "RESOURCE_COLLISION", + `RESOURCE_COLLISION: multiple upstream resources resolve to '${exposedUri}' for profile '${profile}'` + ); + } + const route: ResourceRoute = { profile, upstreamName, originalUri, exposedUri }; + routes.set(exposedUri, route); + this.resourceRoutes.set(profile, routes); + return route; + } +} + +function namespaceResourceUri(upstreamName: string, uri: string): string { + return `miftah://resource/${encodeURIComponent(upstreamName)}?uri=${encodeURIComponent(uri)}`; +} + +function namespaceName(upstreamName: string, name: string): string { + return `${upstreamName}__${name}`; +} + +function redactResource(resource: Resource): Resource { + return { + ...resource, + uri: redactUri(resource.uri), + icons: redactIconSources(resource.icons) + }; +} + +function redactPrompt(prompt: Prompt): Prompt { + return { + ...prompt, + icons: redactIconSources(prompt.icons) + }; +} + +function redactIconSources(icons: readonly T[] | undefined) { + return icons?.map((icon) => ({ ...icon, src: redactUri(icon.src) })); +} diff --git a/src/secrets/redact.ts b/src/secrets/redact.ts index 8b08fde8..2631531c 100644 --- a/src/secrets/redact.ts +++ b/src/secrets/redact.ts @@ -1,3 +1,5 @@ +import { createHmac, randomUUID } from "node:crypto"; + const bearerPattern = /(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi; const providerTokenPatterns = [ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, @@ -5,6 +7,7 @@ const providerTokenPatterns = [ ]; const camelCaseBoundaryPattern = /([a-z0-9])([A-Z])/g; const nonAlphanumericPattern = /[^a-z0-9]+/; +const invalidUriRedactionKey = randomUUID(); const secretKeyTerms = new Set([ "token", "tokens", @@ -72,6 +75,22 @@ export function createRedactor(secretValues: readonly string[] = []): (value: return (value: T) => redactValue(value, secretValues) as T; } +/** Produces a safe public representation of a URI while retaining only its non-sensitive identity. */ +export function redactUri(uri: string): string { + try { + const value = new URL(uri); + value.username = ""; + value.password = ""; + for (const key of new Set(value.searchParams.keys())) { + value.searchParams.set(key, "[REDACTED]"); + } + value.hash = ""; + return value.toString(); + } catch { + return `miftah-invalid-uri:${createHmac("sha256", invalidUriRedactionKey).update(uri).digest("hex")}`; + } +} + /** Redacts secret values and secret-bearing keys from an arbitrary value. */ export function redactSecrets(value: T, secretValues: readonly string[] = []): T { return createRedactor(secretValues)(value); diff --git a/src/upstream/upstream-session.ts b/src/upstream/upstream-session.ts index 848bacf9..60af40f9 100644 --- a/src/upstream/upstream-session.ts +++ b/src/upstream/upstream-session.ts @@ -4,7 +4,9 @@ import type { CallToolResult, GetPromptRequest, ListPromptsResult, + ListPromptsRequest, ListResourcesResult, + ListResourcesRequest, ListToolsResult, ReadResourceRequest } from "@modelcontextprotocol/sdk/types.js"; @@ -24,16 +26,16 @@ export class UpstreamSession { return this.client.callTool(params) as Promise; } - listResources(): Promise { - return this.client.listResources(); + listResources(params?: ListResourcesRequest["params"]): Promise { + return this.client.listResources(params); } readResource(params: ReadResourceRequest["params"]) { return this.client.readResource(params); } - listPrompts(): Promise { - return this.client.listPrompts(); + listPrompts(params?: ListPromptsRequest["params"]): Promise { + return this.client.listPrompts(params); } getPrompt(params: GetPromptRequest["params"]) { diff --git a/src/utils/errors.ts b/src/utils/errors.ts index d5211680..4d0ef4c2 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -27,7 +27,15 @@ export type MiftahErrorCode = | "POLICY_BLOCKED" | "TOOL_COLLISION" | "TOOL_NOT_FOUND" - | "TOOL_SCHEMA_MISMATCH"; + | "TOOL_SCHEMA_MISMATCH" + | "RESOURCE_COLLISION" + | "RESOURCE_NOT_FOUND" + | "RESOURCE_CURSOR_INVALID" + | "RESOURCE_DISCOVERY_INVALIDATED" + | "PROMPT_COLLISION" + | "PROMPT_NOT_FOUND" + | "PROMPT_CURSOR_INVALID" + | "PROMPT_DISCOVERY_INVALIDATED"; export interface MiftahErrorDetails { readonly diagnostics?: readonly ConfigDiagnostic[]; diff --git a/tests/fixtures/fake-upstream.mjs b/tests/fixtures/fake-upstream.mjs index 35d60270..8a6f70a4 100644 --- a/tests/fixtures/fake-upstream.mjs +++ b/tests/fixtures/fake-upstream.mjs @@ -13,6 +13,17 @@ import { const account = process.env.TEST_ACCOUNT_NAME ?? "unknown"; const listToolsDelayMs = Number(process.env.TEST_LIST_TOOLS_DELAY_MS ?? "0"); +const resourceName = process.env.TEST_RESOURCE_NAME ?? "Current account"; +const resourceUri = process.env.TEST_RESOURCE_URI ?? "account://current"; +const promptName = process.env.TEST_PROMPT_NAME ?? "account_prompt"; +const paginateCapabilities = process.env.TEST_PAGINATE_CAPABILITIES === "true"; +const secondResourceName = process.env.TEST_SECOND_RESOURCE_NAME ?? "Second account"; +const secondResourceUri = process.env.TEST_SECOND_RESOURCE_URI ?? "account://second"; +const secondPromptName = process.env.TEST_SECOND_PROMPT_NAME ?? "second_prompt"; +const additionalResourceUri = process.env.TEST_ADDITIONAL_RESOURCE_URI; +const resourceIconUri = process.env.TEST_RESOURCE_ICON_URI; +const promptIconUri = process.env.TEST_PROMPT_ICON_URI; +const promptResourceUri = process.env.TEST_PROMPT_RESOURCE_URI; const failOnRestartPath = process.env.TEST_FAIL_ON_RESTART_PATH; if (failOnRestartPath) { if (existsSync(failOnRestartPath)) { @@ -119,57 +130,94 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return { content: [{ type: "text", text: `created:${String(request.params.arguments?.name ?? "")}` }] }; }); -server.setRequestHandler(ListResourcesRequestSchema, async () => { +server.setRequestHandler(ListResourcesRequestSchema, async (request) => { if (process.env.TEST_FAIL_LIST_RESOURCES === "true") { throw new Error(`test resource discovery failure: ${process.env.API_TOKEN}`); } + const secondPage = paginateCapabilities && request.params?.cursor === "next"; return { resources: [ { - uri: "account://current", + uri: secondPage ? secondResourceUri : resourceUri, name: process.env.TEST_INCLUDE_DISCOVERY_TOKEN === "true" ? `Current account ${process.env.API_TOKEN}` - : "Current account", - mimeType: "text/plain" + : secondPage ? secondResourceName : resourceName, + mimeType: "text/plain", + ...(resourceIconUri ? { icons: [{ src: resourceIconUri }] } : {}) } - ] + ], + ...(paginateCapabilities && !secondPage ? { nextCursor: "next" } : {}) }; }); server.setRequestHandler(ReadResourceRequestSchema, async () => { + if (process.env.TEST_READ_RESOURCE_COUNT_PATH) { + appendFileSync(process.env.TEST_READ_RESOURCE_COUNT_PATH, "1\n"); + } if (process.env.TEST_FAIL_READ_RESOURCE === "true") { throw new Error(`test resource read failure: ${process.env.API_TOKEN}`); } return { - contents: [{ uri: "account://current", text: account, mimeType: "text/plain" }] + contents: [ + { uri: resourceUri, text: account, mimeType: "text/plain" }, + ...(additionalResourceUri ? [{ uri: additionalResourceUri, text: account, mimeType: "text/plain" }] : []) + ] }; }); -server.setRequestHandler(ListPromptsRequestSchema, async () => { +server.setRequestHandler(ListPromptsRequestSchema, async (request) => { if (process.env.TEST_FAIL_LIST_PROMPTS === "true") { throw new Error(`test prompt discovery failure: ${process.env.API_TOKEN}`); } + const secondPage = paginateCapabilities && request.params?.cursor === "next"; return { prompts: [ { - name: "account_prompt", + name: secondPage ? secondPromptName : promptName, description: process.env.TEST_INCLUDE_DISCOVERY_TOKEN === "true" ? `Account prompt ${process.env.API_TOKEN}` - : "Account prompt" + : secondPage ? "Second account prompt" : "Account prompt", + ...(promptIconUri ? { icons: [{ src: promptIconUri }] } : {}) } - ] + ], + ...(paginateCapabilities && !secondPage ? { nextCursor: "next" } : {}) }; }); server.setRequestHandler(GetPromptRequestSchema, async () => { + if (process.env.TEST_GET_PROMPT_COUNT_PATH) { + appendFileSync(process.env.TEST_GET_PROMPT_COUNT_PATH, "1\n"); + } if (process.env.TEST_FAIL_GET_PROMPT === "true") { throw new Error(`test prompt get failure: ${process.env.API_TOKEN}`); } return { - description: "Account prompt", - messages: [{ role: "user", content: { type: "text", text: account } }] + description: promptName, + messages: [ + { role: "user", content: { type: "text", text: account } }, + ...(promptResourceUri + ? [ + { + role: "assistant", + content: { + type: "resource_link", + uri: promptResourceUri, + name: "Account resource", + ...(promptIconUri ? { icons: [{ src: promptIconUri }] } : {}) + } + }, + { + role: "assistant", + content: { + type: "resource", + resource: { uri: promptResourceUri, text: account, mimeType: "text/plain" } + } + } + ] + : []) + ] }; }); diff --git a/tests/helpers/notifications.ts b/tests/helpers/notifications.ts index ee589fa8..41ae1152 100644 --- a/tests/helpers/notifications.ts +++ b/tests/helpers/notifications.ts @@ -3,7 +3,7 @@ import { expect } from "vitest"; export const notificationSettleMs = 50; -export async function expectExactlyOneToolListChanged(count: () => number): Promise { +export async function expectExactlyOneNotification(count: () => number): Promise { await expect.poll(count).toBe(1); await delay(notificationSettleMs); expect(count()).toBe(1); diff --git a/tests/mcp-wrapper.test.ts b/tests/mcp-wrapper.test.ts index 3328f41f..57aad7f3 100644 --- a/tests/mcp-wrapper.test.ts +++ b/tests/mcp-wrapper.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { expectExactlyOneToolListChanged } from "./helpers/notifications.js"; +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"; @@ -124,7 +124,7 @@ describe("Miftah MCP wrapper", () => { expect(client.getServerCapabilities()).toMatchObject({ tools: { listChanged: true } }); await client.callTool({ name: "miftah_use_profile", arguments: { profile: "personal" } }); - await expectExactlyOneToolListChanged(() => notifications); + await expectExactlyOneNotification(() => notifications); } finally { await client.close(); await wrapper.close(); @@ -516,7 +516,7 @@ describe("Miftah MCP wrapper", () => { await client.listTools(); expect((await readFile(countPath, "utf8")).trim().split("\n")).toEqual(["1", "1"]); - await expectExactlyOneToolListChanged(() => notifications); + await expectExactlyOneNotification(() => notifications); } finally { await client.close(); await wrapper.close(); diff --git a/tests/multi-upstream.test.ts b/tests/multi-upstream.test.ts index a46a58cd..f855722c 100644 --- a/tests/multi-upstream.test.ts +++ b/tests/multi-upstream.test.ts @@ -5,7 +5,9 @@ import { GetPromptResultSchema, ListPromptsResultSchema, ListResourcesResultSchema, + PromptListChangedNotificationSchema, ReadResourceResultSchema, + ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"; import { access, mkdtemp, readFile, writeFile } from "node:fs/promises"; @@ -14,7 +16,7 @@ import { dirname, join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { expectExactlyOneToolListChanged } from "./helpers/notifications.js"; +import { expectExactlyOneNotification } from "./helpers/notifications.js"; import { validateConfig } from "../src/config/validate-config.js"; import { ProfileManager } from "../src/profiles/profile-manager.js"; import { MiftahServer } from "../src/mcp/server/miftah-server.js"; @@ -22,7 +24,14 @@ import { MultiUpstreamProcessManager } from "../src/upstream/multi-upstream-proc import { MiftahError } from "../src/utils/errors.js"; const fixture = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "fake-upstream.mjs"); +const promptCollisionPattern = /PROMPT_COLLISION/; +const resourceCollisionPattern = /RESOURCE_COLLISION/; const upstreamToolListFailedPattern = /UPSTREAM_TOOL_LIST_FAILED/; +const resourceCursorInvalidPattern = /RESOURCE_CURSOR_INVALID/; +const promptCursorInvalidPattern = /PROMPT_CURSOR_INVALID/; +const githubResourceRoutePattern = /^miftah:\/\/resource\/github\?/; +const resourceNotFoundPattern = /RESOURCE_NOT_FOUND/; +const promptNotFoundPattern = /PROMPT_NOT_FOUND/; describe("multi-upstream wrapper", () => { it("does not proxy resources or prompts when no upstream is configured", async () => { @@ -80,7 +89,7 @@ describe("multi-upstream wrapper", () => { } }); - it("does not proxy resources or prompts from an ambiguous multi-upstream bundle", async () => { + it("advertises resources and prompts for a multi-upstream bundle", async () => { const config = validateConfig({ version: "1", name: "bundle", @@ -106,34 +115,15 @@ describe("multi-upstream wrapper", () => { try { await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]); - expect(client.getServerCapabilities()).not.toHaveProperty("resources"); - expect(client.getServerCapabilities()).not.toHaveProperty("prompts"); - expect(client.getInstructions()).toContain("multi-upstream"); - - for (const { request, resultSchema } of [ - { request: { method: "resources/list", params: {} }, resultSchema: ListResourcesResultSchema }, - { - request: { method: "resources/read", params: { uri: "account://current" } }, - resultSchema: ReadResourceResultSchema - }, - { request: { method: "prompts/list", params: {} }, resultSchema: ListPromptsResultSchema }, - { - request: { method: "prompts/get", params: { name: "account_prompt" } }, - resultSchema: GetPromptResultSchema - } - ]) { - await expect(client.request(request, resultSchema)).rejects.toMatchObject({ code: -32601 }); - } + expect(client.getServerCapabilities()).toMatchObject({ resources: {}, prompts: {} }); + expect(client.getInstructions()).not.toContain("multi-upstream"); const health = await client.callTool({ name: "miftah_health", arguments: {} }, CallToolResultSchema); const text = CallToolResultSchema.parse(health).content[0]; expect(text?.type).toBe("text"); if (text?.type !== "text") throw new Error("Expected a text health result"); expect(JSON.parse(text.text)).toMatchObject({ - resourcePromptProxy: { - available: false, - reason: expect.stringContaining("multi-upstream") - }, + resourcePromptProxy: { available: true }, upstreams: [] }); } finally { @@ -142,6 +132,410 @@ describe("multi-upstream wrapper", () => { } }); + it("aggregates namespaced resources and routes each read to its originating upstream", async () => { + const config = validateConfig({ + version: "1", + name: "bundle", + defaultProfile: "work", + upstreams: { + github: { transport: "stdio", command: process.execPath, args: [fixture] }, + sentry: { transport: "stdio", command: process.execPath, args: [fixture] } + }, + profiles: { + work: { + upstreams: { + github: { env: { TEST_ACCOUNT_NAME: "github-work" } }, + sentry: { env: { TEST_ACCOUNT_NAME: "sentry-work" } } + } + } + } + }); + const manager = new MultiUpstreamProcessManager(config, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer(config, new ProfileManager(config), manager); + 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(client.getServerCapabilities()).toMatchObject({ resources: {}, prompts: {} }); + const resources = await client.listResources(); + expect(resources.resources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "github__Current account", + uri: "miftah://resource/github?uri=account%3A%2F%2Fcurrent" + }), + expect.objectContaining({ + name: "sentry__Current account", + uri: "miftah://resource/sentry?uri=account%3A%2F%2Fcurrent" + }) + ]) + ); + + const githubResource = resources.resources.find((resource) => resource.name === "github__Current account"); + const sentryResource = resources.resources.find((resource) => resource.name === "sentry__Current account"); + expect(githubResource).toBeDefined(); + expect(sentryResource).toBeDefined(); + if (!githubResource || !sentryResource) throw new Error("Expected namespaced resources."); + + expect(await client.readResource({ uri: githubResource.uri })).toMatchObject({ + contents: [{ uri: githubResource.uri, text: "github-work" }] + }); + expect(await client.readResource({ uri: sentryResource.uri })).toMatchObject({ + contents: [{ uri: sentryResource.uri, text: "sentry-work" }] + }); + + const prompts = await client.listPrompts(); + expect(prompts.prompts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "github__account_prompt" }), + expect.objectContaining({ name: "sentry__account_prompt" }) + ]) + ); + expect(await client.getPrompt({ name: "github__account_prompt" })).toMatchObject({ + messages: [{ content: { text: "github-work" } }] + }); + expect(await client.getPrompt({ name: "sentry__account_prompt" })).toMatchObject({ + messages: [{ content: { text: "sentry-work" } }] + }); + } finally { + await client.close(); + await wrapper.close(); + } + }); + + it("rejects ambiguous namespaced resource and prompt collisions atomically", async () => { + const config = validateConfig({ + version: "1", + name: "bundle", + defaultProfile: "work", + upstreams: { + github: { transport: "stdio", command: process.execPath, args: [fixture] }, + github__sentry: { transport: "stdio", command: process.execPath, args: [fixture] } + }, + profiles: { + work: { + upstreams: { + github: { + env: { + TEST_RESOURCE_NAME: "sentry__Current account", + TEST_PROMPT_NAME: "sentry__account_prompt" + } + }, + github__sentry: { + env: { + TEST_RESOURCE_NAME: "Current account", + TEST_PROMPT_NAME: "account_prompt" + } + } + } + } + } + }); + const manager = new MultiUpstreamProcessManager(config, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer(config, new ProfileManager(config), manager); + 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)]); + + await expect(client.listResources()).rejects.toThrow(resourceCollisionPattern); + await expect(client.listPrompts()).rejects.toThrow(promptCollisionPattern); + } finally { + await client.close(); + await wrapper.close(); + } + }); + + it("preserves independent upstream pagination through opaque aggregate cursors", async () => { + const config = validateConfig({ + version: "1", + name: "bundle", + defaultProfile: "work", + upstreams: { + github: { transport: "stdio", command: process.execPath, args: [fixture] }, + sentry: { transport: "stdio", command: process.execPath, args: [fixture] } + }, + profiles: { + work: { + upstreams: { + github: { env: { TEST_PAGINATE_CAPABILITIES: "true" } }, + sentry: { env: { TEST_PAGINATE_CAPABILITIES: "true" } } + } + } + } + }); + const manager = new MultiUpstreamProcessManager(config, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer(config, new ProfileManager(config), manager); + 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 firstResources = await client.listResources(); + expect(firstResources.resources.map((resource) => resource.name)).toEqual([ + "github__Current account", + "sentry__Current account" + ]); + expect(firstResources.nextCursor).toEqual(expect.any(String)); + expect(firstResources.nextCursor).not.toBe("next"); + if (!firstResources.nextCursor) throw new Error("Expected an aggregate resource cursor."); + + const secondResources = await client.listResources({ cursor: firstResources.nextCursor }); + expect(secondResources.resources.map((resource) => resource.name)).toEqual([ + "github__Second account", + "sentry__Second account" + ]); + expect(secondResources.nextCursor).toBeUndefined(); + + const firstPrompts = await client.listPrompts(); + expect(firstPrompts.prompts.map((prompt) => prompt.name)).toEqual([ + "github__account_prompt", + "sentry__account_prompt" + ]); + expect(firstPrompts.nextCursor).toEqual(expect.any(String)); + expect(firstPrompts.nextCursor).not.toBe("next"); + if (!firstPrompts.nextCursor) throw new Error("Expected an aggregate prompt cursor."); + + const secondPrompts = await client.listPrompts({ cursor: firstPrompts.nextCursor }); + expect(secondPrompts.prompts.map((prompt) => prompt.name)).toEqual([ + "github__second_prompt", + "sentry__second_prompt" + ]); + expect(secondPrompts.nextCursor).toBeUndefined(); + } finally { + await client.close(); + await wrapper.close(); + } + }); + + it("notifies clients to re-list aggregated resources and prompts after a profile change", async () => { + const config = validateConfig({ + version: "1", + name: "bundle", + defaultProfile: "work", + upstreams: { + github: { transport: "stdio", command: process.execPath, args: [fixture] }, + sentry: { transport: "stdio", command: process.execPath, args: [fixture] } + }, + profiles: { + work: { + upstreams: { + github: { env: { TEST_ACCOUNT_NAME: "github-work", TEST_PAGINATE_CAPABILITIES: "true" } }, + sentry: { env: { TEST_ACCOUNT_NAME: "sentry-work", TEST_PAGINATE_CAPABILITIES: "true" } } + } + }, + personal: { + upstreams: { + github: { env: { TEST_ACCOUNT_NAME: "github-personal" } }, + sentry: { env: { TEST_ACCOUNT_NAME: "sentry-personal" } } + } + } + } + }); + const manager = new MultiUpstreamProcessManager(config, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer(config, new ProfileManager(config), manager); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }); + let resourceNotifications = 0; + let promptNotifications = 0; + client.setNotificationHandler(ResourceListChangedNotificationSchema, () => { + resourceNotifications += 1; + }); + client.setNotificationHandler(PromptListChangedNotificationSchema, () => { + promptNotifications += 1; + }); + + try { + await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]); + + expect(client.getServerCapabilities()).toMatchObject({ + resources: { listChanged: true }, + prompts: { listChanged: true } + }); + const workResources = await client.listResources(); + const workPrompts = await client.listPrompts(); + if (!workResources.nextCursor || !workPrompts.nextCursor) { + throw new Error("Expected work-profile aggregate cursors."); + } + await client.callTool({ name: "miftah_use_profile", arguments: { profile: "personal" } }); + await expectExactlyOneNotification(() => resourceNotifications); + await expectExactlyOneNotification(() => promptNotifications); + + const resources = await client.listResources(); + const githubResource = resources.resources.find((resource) => resource.name === "github__Current account"); + expect(githubResource).toBeDefined(); + if (!githubResource) throw new Error("Expected a namespaced GitHub resource."); + expect(await client.readResource({ uri: githubResource.uri })).toMatchObject({ + contents: [{ uri: githubResource.uri, text: "github-personal" }] + }); + expect(await client.getPrompt({ name: "github__account_prompt" })).toMatchObject({ + messages: [{ content: { text: "github-personal" } }] + }); + await client.callTool({ name: "miftah_use_profile", arguments: { profile: "work" } }); + await expect(client.listResources({ cursor: workResources.nextCursor })).rejects.toThrow(resourceCursorInvalidPattern); + await expect(client.listPrompts({ cursor: workPrompts.nextCursor })).rejects.toThrow(promptCursorInvalidPattern); + } finally { + await client.close(); + await wrapper.close(); + } + }); + + it("redacts configured and URI-embedded credentials before publishing a namespaced resource URI", async () => { + const secret = "resource-uri-secret"; + const username = "resource-uri-user"; + const password = "resource-uri-password"; + const queryValue = "resource-uri-query-value"; + const fragment = "resource-uri-fragment"; + const credentialUri = `account://${username}:${password}@current?access_token=${secret}&state=${queryValue}#${fragment}`; + const iconUri = `https://${username}:${password}@icons.example?access_token=${secret}&state=${queryValue}#${fragment}`; + const config = validateConfig({ + version: "1", + name: "bundle", + defaultProfile: "work", + upstreams: { + github: { transport: "stdio", command: process.execPath, args: [fixture] }, + sentry: { transport: "stdio", command: process.execPath, args: [fixture] } + }, + profiles: { + work: { + upstreams: { + github: { + env: { + API_TOKEN: secret, + TEST_ACCOUNT_NAME: "github-work", + TEST_RESOURCE_URI: credentialUri, + TEST_ADDITIONAL_RESOURCE_URI: credentialUri.replace("@current", "@secondary"), + TEST_RESOURCE_ICON_URI: iconUri, + TEST_PROMPT_ICON_URI: iconUri, + TEST_PROMPT_RESOURCE_URI: credentialUri + } + }, + sentry: { env: { TEST_ACCOUNT_NAME: "sentry-work" } } + } + } + } + }); + const manager = new MultiUpstreamProcessManager(config, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer(config, new ProfileManager(config), manager); + 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 resources = await client.listResources(); + const githubResource = resources.resources.find((resource) => resource.name === "github__Current account"); + expect(githubResource).toBeDefined(); + if (!githubResource) throw new Error("Expected a namespaced GitHub resource."); + const publicResources = JSON.stringify(resources); + for (const value of [secret, username, password, queryValue, fragment]) { + expect(publicResources).not.toContain(value); + } + const encodedUpstreamUri = new URL(githubResource.uri).searchParams.get("uri"); + expect(encodedUpstreamUri).toBeDefined(); + expect(decodeURIComponent(encodedUpstreamUri ?? "")).toContain("[REDACTED]"); + const read = await client.readResource({ uri: githubResource.uri }); + expect(read.contents[0]).toMatchObject({ uri: githubResource.uri, text: "github-work" }); + const additionalResourceUri = read.contents[1]?.uri; + expect(additionalResourceUri).toMatch(githubResourceRoutePattern); + const publicRead = JSON.stringify(read); + for (const value of [secret, username, password, queryValue, fragment]) { + expect(publicRead).not.toContain(value); + } + const prompts = await client.listPrompts(); + const publicPrompts = JSON.stringify(prompts); + for (const value of [secret, username, password, queryValue, fragment]) { + expect(publicPrompts).not.toContain(value); + } + const prompt = await client.getPrompt({ name: "github__account_prompt" }); + const publicPrompt = JSON.stringify(prompt); + for (const value of [secret, username, password, queryValue, fragment]) { + expect(publicPrompt).not.toContain(value); + } + const linkedResource = prompt.messages.find((message) => message.content.type === "resource_link")?.content; + expect(linkedResource).toMatchObject({ uri: githubResource.uri }); + if (!linkedResource || linkedResource.type !== "resource_link") { + throw new Error("Expected a resource link in the prompt result."); + } + const linkedRead = await client.readResource({ uri: linkedResource.uri }); + expect(linkedRead.contents[0]).toMatchObject({ uri: linkedResource.uri, text: "github-work" }); + if (!additionalResourceUri) throw new Error("Expected an additional resource URI."); + const additionalRead = await client.readResource({ uri: additionalResourceUri }); + expect(additionalRead.contents[0]).toMatchObject({ uri: githubResource.uri, text: "github-work" }); + } finally { + await client.close(); + await wrapper.close(); + } + }); + + it("uses controlled discovery to route cold namespaced reads and prompt gets exactly", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-cold-resource-prompt-")); + const githubReadPath = join(directory, "github-read-count"); + const sentryReadPath = join(directory, "sentry-read-count"); + const githubPromptPath = join(directory, "github-prompt-count"); + const sentryPromptPath = join(directory, "sentry-prompt-count"); + const config = validateConfig({ + version: "1", + name: "bundle", + defaultProfile: "work", + upstreams: { + github: { transport: "stdio", command: process.execPath, args: [fixture] }, + sentry: { transport: "stdio", command: process.execPath, args: [fixture] } + }, + profiles: { + work: { + upstreams: { + github: { + env: { + TEST_ACCOUNT_NAME: "github-work", + TEST_READ_RESOURCE_COUNT_PATH: githubReadPath, + TEST_GET_PROMPT_COUNT_PATH: githubPromptPath + } + }, + sentry: { + env: { + TEST_ACCOUNT_NAME: "sentry-work", + TEST_READ_RESOURCE_COUNT_PATH: sentryReadPath, + TEST_GET_PROMPT_COUNT_PATH: sentryPromptPath + } + } + } + } + } + }); + const manager = new MultiUpstreamProcessManager(config, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer(config, new ProfileManager(config), manager); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }); + const githubResourceUri = "miftah://resource/github?uri=account%3A%2F%2Fcurrent"; + + try { + await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]); + + expect(await client.readResource({ uri: githubResourceUri })).toMatchObject({ + contents: [{ uri: githubResourceUri, text: "github-work" }] + }); + expect(await client.getPrompt({ name: "github__account_prompt" })).toMatchObject({ + messages: [{ content: { text: "github-work" } }] + }); + await expect(client.readResource({ uri: "miftah://resource/github?uri=account%3A%2F%2Funknown" })).rejects.toThrow( + resourceNotFoundPattern + ); + await expect(client.getPrompt({ name: "github__unknown_prompt" })).rejects.toThrow(promptNotFoundPattern); + expect((await readFile(githubReadPath, "utf8")).trim().split("\n")).toEqual(["1"]); + expect((await readFile(githubPromptPath, "utf8")).trim().split("\n")).toEqual(["1"]); + await expect(access(sentryReadPath)).rejects.toThrow(); + await expect(access(sentryPromptPath)).rejects.toThrow(); + } finally { + await client.close(); + await wrapper.close(); + } + }); + it("proxies resources and prompts through the explicitly selected sole upstream", async () => { const config = validateConfig({ version: "1", @@ -153,7 +547,7 @@ describe("multi-upstream wrapper", () => { profiles: { work: { upstreams: { - github: { env: { TEST_ACCOUNT_NAME: "github-work" } } + github: { env: { TEST_ACCOUNT_NAME: "github-work", TEST_PAGINATE_CAPABILITIES: "true" } } } } } @@ -167,14 +561,24 @@ describe("multi-upstream wrapper", () => { await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]); expect(client.getServerCapabilities()).toMatchObject({ resources: {}, prompts: {} }); - expect(await client.listResources()).toMatchObject({ - resources: [{ uri: "account://current" }] + const firstResources = await client.listResources(); + expect(firstResources).toMatchObject({ + resources: [{ uri: "account://current" }], + nextCursor: "next" + }); + expect(await client.listResources({ cursor: firstResources.nextCursor })).toMatchObject({ + resources: [{ uri: "account://second" }] }); expect(await client.readResource({ uri: "account://current" })).toMatchObject({ contents: [{ text: "github-work" }] }); - expect(await client.listPrompts()).toMatchObject({ - prompts: [{ name: "account_prompt" }] + const firstPrompts = await client.listPrompts(); + expect(firstPrompts).toMatchObject({ + prompts: [{ name: "account_prompt" }], + nextCursor: "next" + }); + expect(await client.listPrompts({ cursor: firstPrompts.nextCursor })).toMatchObject({ + prompts: [{ name: "second_prompt" }] }); expect(await client.getPrompt({ name: "account_prompt" })).toMatchObject({ messages: [{ content: { text: "github-work" } }] @@ -385,9 +789,17 @@ describe("multi-upstream wrapper", () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "test-client", version: "1.0.0" }); let notifications = 0; + let resourceNotifications = 0; + let promptNotifications = 0; client.setNotificationHandler(ToolListChangedNotificationSchema, () => { notifications += 1; }); + client.setNotificationHandler(ResourceListChangedNotificationSchema, () => { + resourceNotifications += 1; + }); + client.setNotificationHandler(PromptListChangedNotificationSchema, () => { + promptNotifications += 1; + }); try { await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]); @@ -409,7 +821,9 @@ describe("multi-upstream wrapper", () => { expect(afterPids.every((pid, index) => pid !== beforePids[index])).toBe(true); expect((await readFile(githubCountPath, "utf8")).trim().split("\n")).toEqual(["1", "1"]); expect((await readFile(sentryCountPath, "utf8")).trim().split("\n")).toEqual(["1", "1"]); - await expectExactlyOneToolListChanged(() => notifications); + await expectExactlyOneNotification(() => notifications); + await expectExactlyOneNotification(() => resourceNotifications); + await expectExactlyOneNotification(() => promptNotifications); } finally { await client.close(); await wrapper.close(); @@ -459,7 +873,7 @@ describe("multi-upstream wrapper", () => { ); expect(restarted.isError).toBe(true); await expect(client.listTools()).rejects.toThrow(upstreamToolListFailedPattern); - await expectExactlyOneToolListChanged(() => notifications); + await expectExactlyOneNotification(() => notifications); } finally { await client.close(); await wrapper.close(); diff --git a/tests/package-contract.test.ts b/tests/package-contract.test.ts index de3ea22a..e302db2a 100644 --- a/tests/package-contract.test.ts +++ b/tests/package-contract.test.ts @@ -22,6 +22,7 @@ interface PackResult { const repositoryRoot = fileURLToPath(new URL("..", import.meta.url)); const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +const npmCommandTimeoutMs = 25_000; const requiredPackPaths = [ "LICENSE", "README.md", @@ -38,11 +39,22 @@ function readPackageManifest(): PackageManifest { } function runNpm(args: readonly string[]) { - return spawnSync(npmCommand, args, { + const result = spawnSync(npmCommand, args, { cwd: repositoryRoot, encoding: "utf8", - env: { ...process.env, npm_config_loglevel: "silent" } + env: { ...process.env, npm_config_loglevel: "silent" }, + timeout: npmCommandTimeoutMs, + killSignal: "SIGTERM" }); + if (result.error) { + const timedOut = "code" in result.error && result.error.code === "ETIMEDOUT"; + const reason = + timedOut + ? `timed out after ${npmCommandTimeoutMs}ms` + : `could not start: ${result.error.message}`; + throw new Error(`npm ${args.join(" ")} ${reason}`); + } + return result; } async function loadPackVerifier(): Promise { @@ -50,12 +62,15 @@ async function loadPackVerifier(): Promise { return import("../scripts/check-pack.mjs") as Promise; } -beforeAll(() => { - const build = runNpm(["run", "build"]); - if (build.status !== 0) { - throw new Error(`Package-contract build failed:\n${build.stderr || build.stdout}`); - } -}); +beforeAll( + () => { + const build = runNpm(["run", "build"]); + if (build.status !== 0) { + throw new Error(`Package-contract build failed:\n${build.stderr || build.stdout}`); + } + }, + 30_000 +); describe("package metadata contract", () => { it("identifies the public repository and package support URLs", () => { diff --git a/tests/resource-prompt-registry.test.ts b/tests/resource-prompt-registry.test.ts new file mode 100644 index 00000000..e8815ce4 --- /dev/null +++ b/tests/resource-prompt-registry.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { ResourcePromptRegistry } from "../src/mcp/server/resource-prompt-registry.js"; + +describe("resource and prompt registry", () => { + it("bounds opaque cursor storage with least-recently-used eviction", async () => { + const registry = new ResourcePromptRegistry( + () => ["github"], + async () => ({ resources: [], nextCursor: "next" }), + async () => ({ prompts: [], nextCursor: "next" }), + (value) => value, + 2 + ); + + const first = await registry.listResources("work"); + const second = await registry.listResources("work"); + const third = await registry.listResources("work"); + if (!first.nextCursor || !second.nextCursor || !third.nextCursor) { + throw new Error("Expected opaque aggregate cursors."); + } + + await expect(registry.listResources("work", first.nextCursor)).rejects.toMatchObject({ + code: "RESOURCE_CURSOR_INVALID" + }); + await expect(registry.listResources("work", second.nextCursor)).resolves.toMatchObject({ resources: [] }); + }); + + it("does not publish a discovery invalidated while an upstream list is in flight", async () => { + let resolveDiscovery: + | ((value: { resources: Array<{ uri: string; name: string; mimeType: string }> }) => void) + | undefined; + const discovery = new Promise<{ resources: Array<{ uri: string; name: string; mimeType: string }> }>((resolve) => { + resolveDiscovery = resolve; + }); + const registry = new ResourcePromptRegistry( + () => ["github"], + async () => discovery, + async () => ({ prompts: [] }), + (value) => value + ); + + const listing = registry.listResources("work"); + registry.invalidate("work"); + if (!resolveDiscovery) throw new Error("Expected resource discovery to start."); + resolveDiscovery({ + resources: [{ uri: "account://current", name: "Current account", mimeType: "text/plain" }] + }); + + await expect(listing).rejects.toMatchObject({ code: "RESOURCE_DISCOVERY_INVALIDATED" }); + expect( + registry.resolveResource("work", "miftah://resource/github?uri=account%3A%2F%2Fcurrent") + ).toBeUndefined(); + }); +}); diff --git a/tests/secrets.test.ts b/tests/secrets.test.ts index af03596a..69960cf6 100644 --- a/tests/secrets.test.ts +++ b/tests/secrets.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { createRedactor, redactSecrets } from "../src/secrets/redact.js"; +import { createRedactor, redactSecrets, redactUri } from "../src/secrets/redact.js"; + +const opaqueInvalidUriPattern = /^miftah-invalid-uri:[a-f0-9]{64}$/; describe("secret redaction", () => { it("redacts configured secret values in nested data", () => { @@ -53,6 +55,26 @@ describe("secret redaction", () => { } }); + it("removes URI userinfo, query values, and fragments from public identifiers", () => { + expect( + redactUri( + "account://resource-uri-user:resource-uri-password@current/path?access_token=resource-uri-secret&state=resource-uri-query-value#resource-uri-fragment" + ) + ).toBe("account://current/path?access_token=%5BREDACTED%5D&state=%5BREDACTED%5D"); + }); + + it("uses stable opaque identifiers for distinct invalid URI values", () => { + const first = redactUri("/relative/account?access_token=secret-one"); + const second = redactUri("/relative/account?access_token=secret-two"); + + expect(first).toMatch(opaqueInvalidUriPattern); + expect(first).toBe(redactUri("/relative/account?access_token=secret-one")); + expect(second).toMatch(opaqueInvalidUriPattern); + expect(second).not.toBe(first); + expect(first).not.toContain("secret-one"); + expect(second).not.toContain("secret-two"); + }); + it("preserves non-secret identifiers and benign key names", () => { const cases = [ { key: "gitSha", value: "0123456789abcdef0123456789abcdef01234567" },