diff --git a/README.md b/README.md index 82a9e9c..b86d649 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ > **Published package — `@theorvane/type-mcp@0.3.2`:** provides standard decorators, a separate `@theorvane/type-mcp/legacy` entrypoint for CommonJS legacy decorators, definition validation, explicit instance resolution, MCP SDK compilation, stdio, `@theorvane/type-mcp/http` Streamable HTTP, and the tools-only `@theorvane/type-mcp/langchain` adapter. > -> **Current `dev` source:** additionally includes SDK v2 protocol negotiation, modern component metadata, tool output schemas, explicit prompt arguments, resource URI templates, completion, invocation context, protocol-backed testing, and image/audio helpers. The examples and capability map below target current source unless they explicitly say “published package.” +> **Current `dev` source:** additionally includes SDK v2 protocol negotiation, modern component metadata, tool output schemas, explicit prompt arguments, resource URI templates, completion, invocation context, protocol-backed testing, image/audio helpers, and component visibility. The examples and capability map below target current source unless they explicitly say “published package.” > > **Integration boundary:** LangGraph `ToolNode` composition, graph topology, model choice, authorization, state, persistence, and deployment remain consumer responsibilities. @@ -134,6 +134,7 @@ The methods above are ordinary application methods. In current source, use `crea | `createMcpServer()` | Available | Validates declarations and compiles the decorated server surface with an explicit resolver seam. | | `McpInvocationContext` | Available | Optional final handler argument exposing request/session identity, cancellation, and progress reporting. | | `McpImage` / `McpAudio` | Available | Browser-neutral byte helpers normalized to standard MCP media content. | +| `enableMcpComponents()` / `disableMcpComponents()` | Available | SDK-native server visibility filtered by key, name/URI, tag, or component kind. | | `@theorvane/type-mcp/testing` | Available | Connects the official SDK client and a compiled server through the in-memory protocol transport. | | `serveStdioServer()` / `startStdioServer()` | Available | SDK v2 factory-based 2025/2026 negotiation plus an instance-based 2025 compatibility helper. | | `@theorvane/type-mcp/http` / `createMcpHandler()` | Available | Fetch/Streamable HTTP adapter with stateful 2025 sessions and the SDK v2 2026 per-request lifecycle; applications own route hosting, durable session policy, and authorization. | @@ -149,6 +150,7 @@ The methods above are ordinary application methods. In current source, use `crea - [Dynamic prompts and resources](docs/guides/dynamic-declarations.md) — explicit prompt arguments, URI templates, and completion in current source. - [Invocation context](docs/guides/invocation-context.md) — request identity, cancellation, progress, and streaming constraints. - [Testing and media helpers](docs/guides/testing-media.md) — in-memory protocol sessions and image/audio byte results. +- [Component visibility](docs/guides/component-visibility.md) — static state, runtime filters, allowlists, and security boundaries. - [Configuration and compatibility](docs/guides/configuration.md) — Node, ESM/CommonJS, TypeScript decorators, schemas, and release boundaries. - [Agent integration guide](docs/guides/agent-integration.md) — evidence-first coding-agent workflow and explicit runtime boundaries. - [HTTP framework integration](docs/guides/http-and-nextjs.md) — published Streamable HTTP example and Fetch/Next.js route shape. diff --git a/docs/api/decorator-api.md b/docs/api/decorator-api.md index 72fc06c..ac56f8a 100644 --- a/docs/api/decorator-api.md +++ b/docs/api/decorator-api.md @@ -92,6 +92,20 @@ summarizeProduct(input: { readonly sku: string }) { | Runtime | SDK v2 derives the MCP prompt argument list, validates request strings through the schema, dispatches completion, and passes the parsed object to the handler. Results and handler failures retain TypeMCP normalization and safe errors. | | Excluded | Automatic argument inference from TypeScript parameter types and prompt template files. Tool icons and prompt icons/custom metadata remain excluded until the compiler exposes them. | +## Component visibility + +Tool, resource/template, and prompt options accept `enabled?: boolean` and `tags?: readonly string[]`. Components default to enabled. Tags must be unique non-empty strings and are copied/frozen with definition metadata. + +`enableMcpComponents(server, filter)` and `disableMcpComponents(server, filter)` use SDK registered handles. Filters match additively by deterministic key, name/URI, tag, or kind; `matchAll` must be explicit for an all-component operation. Enable accepts `only: true` to establish an exact allowlist. + +| Case | Behavior | +| --- | --- | +| Static disabled | Hidden from SDK lists and rejected at dispatch. | +| Runtime transition | SDK list/call behavior and `list_changed` notifications are preserved. The function returns the number of matched components. | +| Excluded | Authentication, authorization, per-session policy, providers, version filtering, and persistence. | + +Visibility is surface shaping, not a security boundary. See the [component visibility guide](../guides/component-visibility.md). + ## Invocation context Decorated handlers may declare a final `McpInvocationContext` argument. Tools and URI-template resources receive it after parsed input; static resources and zero-argument prompts receive it as their only argument; prompts with explicit arguments receive it after parsed input. diff --git a/docs/architecture/adr/0006-component-visibility.md b/docs/architecture/adr/0006-component-visibility.md new file mode 100644 index 0000000..729f89c --- /dev/null +++ b/docs/architecture/adr/0006-component-visibility.md @@ -0,0 +1,19 @@ +# ADR 0006: Add server-level component visibility + +- **Status:** Accepted +- **Date:** 2026-08-27 +- **Issue:** [#184](https://github.com/Theorvane/type-mcp/issues/184) + +## Context + +FastMCP supports static and dynamic visibility by component identity and tags. The official TypeScript SDK v2 registered handles already implement enabled state, list filtering, call blocking, and list_changed notifications. TypeMCP needs a metadata and filtering layer without treating visibility as authorization. + +## Decision + +Tool, resource/template, and prompt options gain enabled and tags. Tags are validated, copied, and frozen but remain TypeMCP-local metadata. The compiler tracks SDK registered handles in a WeakMap keyed by the compiled server and applies initial disabled state through native handles. + +enableMcpComponents and disableMcpComponents match server components by generated key, public name or URI, tag, or kind. Empty filters are rejected unless matchAll is explicit. Enable supports only mode to establish an allowlist. Native SDK handle transitions preserve protocol listing, dispatch, and change notifications. + +## Consequences + +Visibility changes are process-local and server-wide. They are useful for feature flags and surface shaping, but are not an authentication or authorization boundary. Per-session policy, providers, version constraints, and persistence remain excluded. diff --git a/docs/guides/component-visibility.md b/docs/guides/component-visibility.md new file mode 100644 index 0000000..1b7669e --- /dev/null +++ b/docs/guides/component-visibility.md @@ -0,0 +1,50 @@ +# Component visibility + +**Availability:** current `dev` source; not included in published `0.3.2`. + +Tools, resources/templates, and prompts accept `enabled` and `tags`. Components are enabled by default. + +```ts +@McpTool({ + input: z.object({}), + enabled: false, + tags: ["admin", "dangerous"], +}) +deleteEverything() { + return "deleted"; +} +``` + +A disabled component remains registered, but the official SDK omits it from listings and rejects dispatch as unknown. Tags are TypeMCP-local metadata and must be unique non-empty strings. Definition reads return copied, frozen tag arrays. + +## Runtime controls + +```ts +import { + disableMcpComponents, + enableMcpComponents, +} from "@theorvane/type-mcp"; + +const server = await createMcpServer(AdminServer); + +enableMcpComponents(server, { tags: ["admin"] }); +disableMcpComponents(server, { keys: ["tool:deleteEverything"] }); +enableMcpComponents(server, { tags: ["safe"], only: true }); +``` + +Filters combine additively: a component matches when any key, name/URI, tag, or kind matches. Supported kinds are `tool`, `resource`, `template`, and `prompt`. Use `matchAll: true` for an explicit all-components operation; an empty filter is rejected. + +Keys are deterministic: + +- `tool:{name}` +- `resource:{uri}` +- `template:{uriTemplate}` +- `prompt:{name}` + +`only: true` establishes an allowlist: matches are enabled and every other tracked component is disabled. Later calls override current state. Functions return the number of matched components. + +TypeMCP calls native SDK registered handles, preserving listing, dispatch blocking, and `list_changed` notifications. + +## Security boundary + +Visibility is process-local surface shaping for feature flags and maintenance. It is not authentication or authorization. Authorize every operation at the host or handler boundary even when hidden. Per-session visibility and persistent policies are outside this API. diff --git a/docs/planning/2026-08-27_issue-184-component-visibility.md b/docs/planning/2026-08-27_issue-184-component-visibility.md new file mode 100644 index 0000000..4c11d8c --- /dev/null +++ b/docs/planning/2026-08-27_issue-184-component-visibility.md @@ -0,0 +1,44 @@ +# Task brief — component visibility + +**Owner:** Codex + +**Date:** 2026-08-27 + +**Status:** complete + +**Related plan:** GitHub issue `#184` + +**Stacked base:** PR `#183` / `feat/182-testing-media` until merged to `dev` + +## Objective + +Decorated components can start disabled and compiled servers can safely reshape their exposed surface through SDK-native visibility handles. + +## Scope + +**In:** enabled, immutable validated tags, keys/names/tags/kinds filters, allowlist mode, native list/call behavior and notifications, docs. + +**Out:** per-session policy, authentication, authorization, providers, versions, persistence, OAuth, and MCP Apps. + +## Acceptance criteria + +- [x] Static disabled state hides listings and blocks dispatch. +- [x] Runtime enable/disable filters match keys, names/URIs, tags, and kinds. +- [x] Allowlist mode produces the exact selected surface. +- [x] Tags are validated and frozen in definitions. +- [x] Existing untagged enabled components remain compatible. +- [x] Full package verification passes. + +## Red → green evidence + +| Stage | Command | Result / expected reason | +| --- | --- | --- | +| Red | `npx vitest run test/component-visibility.test.ts` | Failed as expected because decorator tags were not stored. | +| Green | `npx vitest run test/component-visibility.test.ts` | Passed: 2 tests cover static/runtime behavior, every filter family, allowlist mode, empty-filter safety, immutable tags, and invalid tags. | +| Regression | `npm test` | Passed: 34 files and 93 tests; lint, typecheck, build, package/publish packed consumers, production audit, and diff checks passed. | + +## Review handoff + +- Spec review: pending +- Quality review: pending +- Final checks: all required local checks passed diff --git a/docs/product/mvp-scope.md b/docs/product/mvp-scope.md index 91e920b..e692934 100644 --- a/docs/product/mvp-scope.md +++ b/docs/product/mvp-scope.md @@ -1,6 +1,6 @@ # MVP scope -> **Published package:** `@theorvane/type-mcp@0.3.2` includes the MVP baseline. Current `dev` additionally carries SDK v2 serving, modern server/component metadata, initialization instructions, tool structured output, explicit prompt arguments, resource URI templates, completion, and invocation context. Start with the [README](../../README.md) and [getting-started guide](../guides/getting-started.md) for exact exports and boundaries. +> **Published package:** `@theorvane/type-mcp@0.3.2` includes the MVP baseline. Current `dev` additionally carries SDK v2 serving, modern server/component metadata, initialization instructions, tool structured output, dynamic prompts/resources, invocation context, testing/media helpers, and component visibility. Start with the [README](../../README.md) and [getting-started guide](../guides/getting-started.md) for exact exports and boundaries. **Status:** The baseline is published in `@theorvane/type-mcp@0.3.2`; rows explicitly marked current `dev` are implemented but unreleased. @@ -16,6 +16,7 @@ | Invocation context | Current `dev`: request/session identity, SDK cancellation signal, and progress reporting | | Testing | Current `dev`: official SDK v2 in-memory client/server session with explicit cleanup | | Media | Current `dev`: byte-only image/audio helpers with explicit MIME validation | +| Component visibility | Current `dev`: static enabled state and server-level key/name/tag/kind filtering | | Instance construction | Direct constructor default plus async-capable `InstanceResolver` interface | | Local transport | stdio helper | | Web transport | Fetch-standard Streamable HTTP handler | diff --git a/src/compiler/create-mcp-server.ts b/src/compiler/create-mcp-server.ts index 55bbcd4..4b0c95c 100644 --- a/src/compiler/create-mcp-server.ts +++ b/src/compiler/create-mcp-server.ts @@ -11,6 +11,7 @@ import type { McpServerConstructor, ZeroArgumentMcpServerConstructor, } from "../types.js"; +import { initializeMcpVisibility, trackMcpComponent } from "../visibility.js"; import { normalizePromptResult, normalizeResourceResult, @@ -65,9 +66,10 @@ export async function createMcpServer< ? undefined : { instructions: definition.instructions }, ); + initializeMcpVisibility(server); for (const tool of definition.tools) { - server.registerTool( + const registeredTool = server.registerTool( tool.name, { inputSchema: tool.input, @@ -98,6 +100,17 @@ export async function createMcpServer< } }, ); + trackMcpComponent( + server, + { + key: `tool:${tool.name}`, + name: tool.name, + kind: "tool", + tags: tool.tags, + initiallyEnabled: tool.enabled, + }, + registeredTool, + ); } for (const resource of definition.resources) { @@ -131,7 +144,7 @@ export async function createMcpServer< ...(resource._meta === undefined ? {} : { _meta: { ...resource._meta } }), }; if (!UriTemplate.isTemplate(resource.uri)) { - server.registerResource( + const registeredResource = server.registerResource( resource.name, resource.uri, config, @@ -150,6 +163,18 @@ export async function createMcpServer< } }, ); + trackMcpComponent( + server, + { + key: `resource:${resource.uri}`, + name: resource.name, + identifiers: [resource.uri], + kind: "resource", + tags: resource.tags, + initiallyEnabled: resource.enabled, + }, + registeredResource, + ); continue; } @@ -157,7 +182,7 @@ export async function createMcpServer< if (input === undefined) { throw new TypeError("Validated resource template input is missing"); } - server.registerResource( + const registeredTemplate = server.registerResource( resource.name, createResourceTemplate(resource), config, @@ -181,6 +206,18 @@ export async function createMcpServer< } }, ); + trackMcpComponent( + server, + { + key: `template:${resource.uri}`, + name: resource.name, + identifiers: [resource.uri], + kind: "template", + tags: resource.tags, + initiallyEnabled: resource.enabled, + }, + registeredTemplate, + ); } for (const prompt of definition.prompts) { @@ -191,21 +228,36 @@ export async function createMcpServer< : { description: prompt.description }), }; if (prompt.args === undefined) { - server.registerPrompt(prompt.name, config, async (context: unknown) => { - try { - if (!isServerContext(context)) { - throw new TypeError("MCP prompt context is unavailable"); + const registeredPrompt = server.registerPrompt( + prompt.name, + config, + async (context: unknown) => { + try { + if (!isServerContext(context)) { + throw new TypeError("MCP prompt context is unavailable"); + } + const result = await invokeMethod(instance, prompt.methodName, [ + createMcpInvocationContext(context), + ]); + return normalizePromptResult(result); + } catch { + return normalizePromptResult("Prompt execution failed"); } - const result = await invokeMethod(instance, prompt.methodName, [ - createMcpInvocationContext(context), - ]); - return normalizePromptResult(result); - } catch { - return normalizePromptResult("Prompt execution failed"); - } - }); + }, + ); + trackMcpComponent( + server, + { + key: `prompt:${prompt.name}`, + name: prompt.name, + kind: "prompt", + tags: prompt.tags, + initiallyEnabled: prompt.enabled, + }, + registeredPrompt, + ); } else { - server.registerPrompt( + const registeredPrompt = server.registerPrompt( prompt.name, { ...config, argsSchema: prompt.args }, async (input, context) => { @@ -220,6 +272,17 @@ export async function createMcpServer< } }, ); + trackMcpComponent( + server, + { + key: `prompt:${prompt.name}`, + name: prompt.name, + kind: "prompt", + tags: prompt.tags, + initiallyEnabled: prompt.enabled, + }, + registeredPrompt, + ); } } diff --git a/src/decorators/mcp-prompt.ts b/src/decorators/mcp-prompt.ts index 7ba7928..4ae7249 100644 --- a/src/decorators/mcp-prompt.ts +++ b/src/decorators/mcp-prompt.ts @@ -39,6 +39,8 @@ export function McpPrompt( title: options.title, description: options.description, args: options.args, + enabled: options.enabled, + tags: options.tags, }); }; return decorator as diff --git a/src/decorators/mcp-resource.ts b/src/decorators/mcp-resource.ts index f17ccea..8fa351f 100644 --- a/src/decorators/mcp-resource.ts +++ b/src/decorators/mcp-resource.ts @@ -17,6 +17,8 @@ export function McpResource(options: McpResourceOptions) { _meta: options._meta, input: options.input, complete: options.complete, + enabled: options.enabled, + tags: options.tags, }); }; } diff --git a/src/decorators/mcp-tool.ts b/src/decorators/mcp-tool.ts index 4961c77..0061907 100644 --- a/src/decorators/mcp-tool.ts +++ b/src/decorators/mcp-tool.ts @@ -14,6 +14,8 @@ export function McpTool(options: McpToolOptions) { outputSchema: options.outputSchema, annotations: options.annotations, _meta: options._meta, + enabled: options.enabled, + tags: options.tags, }); }; } diff --git a/src/index.ts b/src/index.ts index d6e10bf..6047d7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ export type { } from "./transports/stdio.js"; export { serveStdioServer, startStdioServer } from "./transports/stdio.js"; export type { + McpComponentVisibilityOptions, McpPromptDefinition, McpPromptOptions, McpResourceCompletion, @@ -33,3 +34,12 @@ export type { McpToolDefinition, McpToolOptions, } from "./types.js"; +export type { + McpComponentKind, + McpComponentVisibilityFilter, + McpEnableComponentsOptions, +} from "./visibility.js"; +export { + disableMcpComponents, + enableMcpComponents, +} from "./visibility.js"; diff --git a/src/legacy.ts b/src/legacy.ts index 8d5ed14..20a5c05 100644 --- a/src/legacy.ts +++ b/src/legacy.ts @@ -80,6 +80,8 @@ export function McpTool(options: McpToolOptions): LegacyMethodDecorator { outputSchema: options.outputSchema, annotations: options.annotations, _meta: options._meta, + enabled: options.enabled, + tags: options.tags, }); }; } @@ -102,6 +104,8 @@ export function McpResource( _meta: options._meta, input: options.input, complete: options.complete, + enabled: options.enabled, + tags: options.tags, }); }; } @@ -116,6 +120,8 @@ export function McpPrompt(options: McpPromptOptions): LegacyMethodDecorator { title: options.title, description: options.description, args: options.args, + enabled: options.enabled, + tags: options.tags, }); }; } diff --git a/src/metadata/definitions.ts b/src/metadata/definitions.ts index cd4ba83..061fb99 100644 --- a/src/metadata/definitions.ts +++ b/src/metadata/definitions.ts @@ -63,6 +63,8 @@ export function freezeServerDefinition( tool._meta === undefined ? undefined : Object.freeze({ ...tool._meta }), + ...(tool.enabled === undefined ? {} : { enabled: tool.enabled }), + ...(tool.tags === undefined ? {} : { tags: freezeTags(tool.tags) }), }), ), ), @@ -95,6 +97,12 @@ export function freezeServerDefinition( resource.complete === undefined ? undefined : Object.freeze({ ...resource.complete }), + ...(resource.enabled === undefined + ? {} + : { enabled: resource.enabled }), + ...(resource.tags === undefined + ? {} + : { tags: freezeTags(resource.tags) }), }), ), ), @@ -106,12 +114,22 @@ export function freezeServerDefinition( title: prompt.title, description: prompt.description, args: prompt.args, + ...(prompt.enabled === undefined ? {} : { enabled: prompt.enabled }), + ...(prompt.tags === undefined + ? {} + : { tags: freezeTags(prompt.tags) }), }), ), ), }); } +function freezeTags( + tags: readonly string[] | undefined, +): readonly string[] | undefined { + return tags === undefined ? undefined : Object.freeze([...tags]); +} + function freezeIcons( icons: readonly Readonly[] | undefined, ): readonly Readonly[] | undefined { diff --git a/src/metadata/read-server-definition.ts b/src/metadata/read-server-definition.ts index bfbd0f0..c65219e 100644 --- a/src/metadata/read-server-definition.ts +++ b/src/metadata/read-server-definition.ts @@ -28,11 +28,18 @@ export function readMcpServerDefinition< } assertUniqueNames("tool", definition.tools, className); + for (const tool of definition.tools) { + assertVisibilityDeclaration("tool", tool, className); + } assertUniqueNames("resource", definition.resources, className); for (const resource of definition.resources) { assertResourceDeclaration(resource, className); + assertVisibilityDeclaration("resource", resource, className); } assertUniqueNames("prompt", definition.prompts, className); + for (const prompt of definition.prompts) { + assertVisibilityDeclaration("prompt", prompt, className); + } return definition; } @@ -55,6 +62,38 @@ function assertUniqueNames( } } +function assertVisibilityDeclaration( + componentType: "tool" | "resource" | "prompt", + definition: NamedDefinition, + className: string, +): void { + if ( + definition.enabled !== undefined && + typeof definition.enabled !== "boolean" + ) { + throw new TypeMcpDefinitionError( + `MCP ${componentType} "${definition.name}" on ${className} has an invalid enabled value`, + ); + } + if (definition.tags === undefined) { + return; + } + if (!Array.isArray(definition.tags)) { + throw new TypeMcpDefinitionError( + `MCP ${componentType} "${definition.name}" on ${className} has invalid tags`, + ); + } + const tags = new Set(); + for (const tag of definition.tags) { + if (typeof tag !== "string" || tag.trim().length === 0 || tags.has(tag)) { + throw new TypeMcpDefinitionError( + `MCP ${componentType} "${definition.name}" on ${className} requires unique non-empty tags`, + ); + } + tags.add(tag); + } +} + function assertResourceDeclaration( resource: McpResourceDefinition, className: string, diff --git a/src/types.ts b/src/types.ts index 34d0851..bfb0dcc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,7 +28,12 @@ export interface McpServerOptions { readonly instructions?: string | undefined; } -export interface McpToolOptions { +export interface McpComponentVisibilityOptions { + readonly enabled?: boolean | undefined; + readonly tags?: readonly string[] | undefined; +} + +export interface McpToolOptions extends McpComponentVisibilityOptions { readonly name?: string | undefined; readonly title?: string | undefined; readonly description?: string | undefined; @@ -38,7 +43,7 @@ export interface McpToolOptions { readonly _meta?: Readonly> | undefined; } -export interface McpResourceOptions { +export interface McpResourceOptions extends McpComponentVisibilityOptions { readonly name?: string | undefined; readonly title?: string | undefined; readonly uri: string; @@ -53,7 +58,7 @@ export interface McpResourceOptions { | undefined; } -export interface McpPromptOptions { +export interface McpPromptOptions extends McpComponentVisibilityOptions { readonly name?: string | undefined; readonly title?: string | undefined; readonly description?: string | undefined; diff --git a/src/visibility.ts b/src/visibility.ts new file mode 100644 index 0000000..aefb2b1 --- /dev/null +++ b/src/visibility.ts @@ -0,0 +1,226 @@ +import type { McpServer } from "@modelcontextprotocol/server"; + +export type McpComponentKind = "tool" | "resource" | "template" | "prompt"; + +export interface McpComponentVisibilityFilter { + readonly keys?: readonly string[] | undefined; + readonly names?: readonly string[] | undefined; + readonly tags?: readonly string[] | undefined; + readonly kinds?: readonly McpComponentKind[] | undefined; + readonly matchAll?: boolean | undefined; +} + +export interface McpEnableComponentsOptions + extends McpComponentVisibilityFilter { + readonly only?: boolean | undefined; +} + +interface VisibilityHandle { + readonly enabled: boolean; + enable(): void; + disable(): void; +} + +export interface TrackedMcpComponent { + readonly key: string; + readonly name: string; + readonly identifiers?: readonly string[] | undefined; + readonly tags?: readonly string[] | undefined; + readonly kind: McpComponentKind; + readonly initiallyEnabled?: boolean | undefined; +} + +interface VisibilityEntry { + readonly key: string; + readonly names: ReadonlySet; + readonly tags: ReadonlySet; + readonly kind: McpComponentKind; + readonly handle: VisibilityHandle; +} + +const visibilityEntries = new WeakMap(); + +export function initializeMcpVisibility(server: McpServer): void { + visibilityEntries.set(server, []); +} + +export function trackMcpComponent( + server: McpServer, + component: TrackedMcpComponent, + handle: VisibilityHandle, +): void { + const entries = visibilityEntries.get(server); + if (entries === undefined) { + throw new TypeError("MCP visibility registry is not initialized"); + } + const entry = Object.freeze({ + key: component.key, + names: new Set([component.name, ...(component.identifiers ?? [])]), + tags: new Set(component.tags ?? []), + kind: component.kind, + handle, + }); + visibilityEntries.set(server, Object.freeze([...entries, entry])); + if (component.initiallyEnabled === false && handle.enabled) { + handle.disable(); + } +} + +export function enableMcpComponents( + server: McpServer, + options: McpEnableComponentsOptions, +): number { + const entries = requireEntries(server); + const filter = parseFilter(options); + if (options.only !== undefined && typeof options.only !== "boolean") { + throw new TypeError("MCP visibility only must be a boolean"); + } + let matched = 0; + for (const entry of entries) { + const selected = matches(entry, filter); + if (selected) { + matched += 1; + } + const shouldEnable = + options.only === true ? selected : selected || entry.handle.enabled; + setEnabled(entry.handle, shouldEnable); + } + return matched; +} + +export function disableMcpComponents( + server: McpServer, + filterValue: McpComponentVisibilityFilter, +): number { + const entries = requireEntries(server); + const filter = parseFilter(filterValue); + let matched = 0; + for (const entry of entries) { + if (!matches(entry, filter)) { + continue; + } + matched += 1; + setEnabled(entry.handle, false); + } + return matched; +} + +interface ParsedVisibilityFilter { + readonly keys: ReadonlySet; + readonly names: ReadonlySet; + readonly tags: ReadonlySet; + readonly kinds: ReadonlySet; + readonly matchAll: boolean; +} + +function parseFilter( + filter: McpComponentVisibilityFilter, +): ParsedVisibilityFilter { + if (typeof filter !== "object" || filter === null) { + throw new TypeError("MCP visibility filter must be an object"); + } + if (filter.matchAll !== undefined && typeof filter.matchAll !== "boolean") { + throw new TypeError("MCP visibility matchAll must be a boolean"); + } + const keys = readStrings(filter.keys, "keys"); + const names = readStrings(filter.names, "names"); + const tags = readStrings(filter.tags, "tags"); + const kinds = readKinds(filter.kinds); + if ( + filter.matchAll !== true && + keys.size === 0 && + names.size === 0 && + tags.size === 0 && + kinds.size === 0 + ) { + throw new TypeError("MCP visibility filter requires criteria or matchAll"); + } + return { keys, names, tags, kinds, matchAll: filter.matchAll === true }; +} + +function readStrings( + values: readonly string[] | undefined, + label: string, +): ReadonlySet { + if (values === undefined) { + return new Set(); + } + if (!Array.isArray(values)) { + throw new TypeError(`MCP visibility ${label} must be an array`); + } + const result = new Set(); + for (const value of values) { + if (typeof value !== "string" || value.length === 0) { + throw new TypeError( + `MCP visibility ${label} must contain non-empty strings`, + ); + } + result.add(value); + } + return result; +} + +function readKinds( + values: readonly McpComponentKind[] | undefined, +): ReadonlySet { + const strings = readStrings(values, "kinds"); + const result = new Set(); + for (const value of strings) { + if ( + value !== "tool" && + value !== "resource" && + value !== "template" && + value !== "prompt" + ) { + throw new TypeError("MCP visibility kinds contains an unknown kind"); + } + result.add(value); + } + return result; +} + +function matches( + entry: VisibilityEntry, + filter: ParsedVisibilityFilter, +): boolean { + return ( + filter.matchAll || + filter.keys.has(entry.key) || + hasIntersection(filter.names, entry.names) || + hasIntersection(filter.tags, entry.tags) || + filter.kinds.has(entry.kind) + ); +} + +function hasIntersection( + left: ReadonlySet, + right: ReadonlySet, +): boolean { + for (const value of left) { + if (right.has(value)) { + return true; + } + } + return false; +} + +function setEnabled(handle: VisibilityHandle, enabled: boolean): void { + if (handle.enabled === enabled) { + return; + } + if (enabled) { + handle.enable(); + } else { + handle.disable(); + } +} + +function requireEntries(server: McpServer): readonly VisibilityEntry[] { + const entries = visibilityEntries.get(server); + if (entries === undefined) { + throw new TypeError( + "MCP visibility controls require a TypeMCP-compiled server", + ); + } + return entries; +} diff --git a/test/component-visibility.test.ts b/test/component-visibility.test.ts new file mode 100644 index 0000000..f21e134 --- /dev/null +++ b/test/component-visibility.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + createMcpServer, + disableMcpComponents, + enableMcpComponents, + getMcpServerDefinition, + McpPrompt, + McpResource, + McpServer, + McpTool, +} from "../src/index.js"; +import { createMcpTestSession } from "../src/testing.js"; + +function methodContext( + name: string, + metadata: DecoratorMetadata, +): ClassMethodDecoratorContext { + return { + kind: "method", + name, + static: false, + private: false, + access: { has: () => true, get: () => () => undefined }, + metadata, + addInitializer: () => undefined, + }; +} + +function classContext(metadata: DecoratorMetadata): ClassDecoratorContext { + return { + kind: "class", + name: "VisibilityServer", + metadata, + addInitializer: () => undefined, + }; +} + +describe("MCP component visibility", () => { + it("controls listings and dispatch through visibility filters", async () => { + class VisibilityServer { + publicTool(): string { + return "public"; + } + adminTool(): string { + return "admin"; + } + secret(): string { + return "secret"; + } + adminPrompt(): string { + return "admin prompt"; + } + } + const metadata: DecoratorMetadata = {}; + McpTool({ input: z.object({}), tags: ["safe"] })( + VisibilityServer.prototype.publicTool, + methodContext("publicTool", metadata), + ); + McpTool({ input: z.object({}), enabled: false, tags: ["admin"] })( + VisibilityServer.prototype.adminTool, + methodContext("adminTool", metadata), + ); + McpResource({ + uri: "secret://config", + enabled: false, + tags: ["admin"], + })(VisibilityServer.prototype.secret, methodContext("secret", metadata)); + McpPrompt({ enabled: false, tags: ["admin"] })( + VisibilityServer.prototype.adminPrompt, + methodContext("adminPrompt", metadata), + ); + McpServer({ name: "visibility", version: "1.0.0" })( + VisibilityServer, + classContext(metadata), + ); + + const definition = getMcpServerDefinition(VisibilityServer); + expect(definition?.tools[0]?.tags).toEqual(["safe"]); + expect(Object.isFrozen(definition?.tools[0]?.tags)).toBe(true); + + const session = await createMcpTestSession( + createMcpServer(VisibilityServer), + ); + try { + expect( + (await session.client.listTools()).tools.map((tool) => tool.name), + ).toEqual(["publicTool"]); + expect((await session.client.listResources()).resources).toEqual([]); + expect((await session.client.listPrompts()).prompts).toEqual([]); + await expect( + session.client.callTool({ name: "adminTool", arguments: {} }), + ).rejects.toThrow(); + expect(() => disableMcpComponents(session.server, {})).toThrow( + /criteria/, + ); + + expect(enableMcpComponents(session.server, { tags: ["admin"] })).toBe(3); + expect( + (await session.client.listTools()).tools + .map((tool) => tool.name) + .sort(), + ).toEqual(["adminTool", "publicTool"]); + + expect( + disableMcpComponents(session.server, { + keys: ["tool:adminTool"], + }), + ).toBe(1); + await expect( + session.client.callTool({ name: "adminTool", arguments: {} }), + ).rejects.toThrow(); + expect(disableMcpComponents(session.server, { kinds: ["prompt"] })).toBe( + 1, + ); + expect(disableMcpComponents(session.server, { names: ["secret"] })).toBe( + 1, + ); + + expect( + enableMcpComponents(session.server, { + tags: ["safe"], + only: true, + }), + ).toBe(1); + expect( + (await session.client.listTools()).tools.map((tool) => tool.name), + ).toEqual(["publicTool"]); + expect((await session.client.listResources()).resources).toEqual([]); + expect((await session.client.listPrompts()).prompts).toEqual([]); + } finally { + await session.close(); + } + }); + + it("rejects duplicate or empty component tags", async () => { + class InvalidTagsServer { + tool(): string { + return "invalid"; + } + } + const metadata: DecoratorMetadata = {}; + McpTool({ + input: z.object({}), + tags: ["duplicate", "duplicate"], + })(InvalidTagsServer.prototype.tool, methodContext("tool", metadata)); + McpServer({ name: "invalid-tags", version: "1.0.0" })( + InvalidTagsServer, + classContext(metadata), + ); + + await expect(createMcpServer(InvalidTagsServer)).rejects.toThrow( + /unique non-empty tags/, + ); + }); +});