@casys/mcp-server 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mod.ts CHANGED
@@ -82,8 +82,16 @@ export type {
82
82
  ToolHandler,
83
83
  } from "./src/types.js";
84
84
 
85
- // MCP Apps constants
85
+ // MCP Apps constants & viewer utilities
86
86
  export { MCP_APP_MIME_TYPE } from "./src/types.js";
87
+ export type {
88
+ RegisterViewersConfig,
89
+ RegisterViewersSummary,
90
+ } from "./src/concurrent-server.js";
91
+ export {
92
+ resolveViewerDistPath,
93
+ discoverViewers,
94
+ } from "./src/ui/viewer-utils.js";
87
95
 
88
96
  // Middleware pipeline
89
97
  export type {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casys/mcp-server",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "description": "Production-ready MCP server framework with concurrency control, auth, and observability",
5
5
  "type": "module",
6
6
  "main": "mod.ts",
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Localhost callback server for OAuth PKCE redirect capture.
3
+ *
4
+ * Starts a temporary HTTP server on localhost to receive the
5
+ * authorization code from the OAuth redirect. Shuts down
6
+ * after receiving the code or on timeout.
7
+ *
8
+ * @module lib/server/client-auth/callback-server
9
+ */
10
+
11
+ export interface CallbackServerOptions {
12
+ /** Port to listen on (0 = auto-assign, default: 0) */
13
+ port?: number;
14
+ /** Timeout in ms (default: 120_000) */
15
+ timeout?: number;
16
+ }
17
+
18
+ const SUCCESS_HTML = `<!DOCTYPE html>
19
+ <html><head><title>PML Auth</title><style>
20
+ body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#08080a;color:#fff}
21
+ .card{text-align:center;padding:2rem}h1{color:#FFB86F}
22
+ </style></head><body><div class="card">
23
+ <h1>Authentication successful</h1>
24
+ <p>You can close this tab and return to the terminal.</p>
25
+ </div></body></html>`;
26
+
27
+ const ERROR_HTML = `<!DOCTYPE html>
28
+ <html><head><title>PML Auth Error</title><style>
29
+ body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#08080a;color:#fff}
30
+ .card{text-align:center;padding:2rem}h1{color:#ff6b6b}
31
+ </style></head><body><div class="card">
32
+ <h1>Authentication failed</h1>
33
+ <p>Missing authorization code. Please try again.</p>
34
+ </div></body></html>`;
35
+
36
+ export class CallbackServer {
37
+ private port: number;
38
+ private timeout: number;
39
+ private abortController: AbortController | null = null;
40
+ private server: Deno.HttpServer | null = null;
41
+ private timerId: ReturnType<typeof setTimeout> | null = null;
42
+ private closeTimerId: ReturnType<typeof setTimeout> | null = null;
43
+
44
+ constructor(options?: CallbackServerOptions) {
45
+ this.port = options?.port ?? 0;
46
+ this.timeout = options?.timeout ?? 120_000;
47
+ }
48
+
49
+ async start(): Promise<{ port: number; codePromise: Promise<string> }> {
50
+ this.abortController = new AbortController();
51
+
52
+ let resolveCode!: (code: string) => void;
53
+ let rejectCode!: (err: Error) => void;
54
+ const codePromise = new Promise<string>((resolve, reject) => {
55
+ resolveCode = resolve;
56
+ rejectCode = reject;
57
+ });
58
+
59
+ this.timerId = setTimeout(() => {
60
+ this.timerId = null;
61
+ rejectCode(
62
+ new Error(
63
+ "OAuth callback timeout — no authorization code received",
64
+ ),
65
+ );
66
+ this.close();
67
+ }, this.timeout);
68
+
69
+ this.server = Deno.serve(
70
+ {
71
+ port: this.port,
72
+ signal: this.abortController.signal,
73
+ onListen: () => {},
74
+ },
75
+ (req) => {
76
+ const url = new URL(req.url);
77
+ if (url.pathname === "/callback") {
78
+ const code = url.searchParams.get("code");
79
+ if (!code) {
80
+ return new Response(ERROR_HTML, {
81
+ status: 400,
82
+ headers: { "Content-Type": "text/html" },
83
+ });
84
+ }
85
+ if (this.timerId) {
86
+ clearTimeout(this.timerId);
87
+ this.timerId = null;
88
+ }
89
+ resolveCode(code);
90
+ // Schedule close after response is sent
91
+ this.closeTimerId = setTimeout(() => {
92
+ this.closeTimerId = null;
93
+ this.close();
94
+ }, 100);
95
+ return new Response(SUCCESS_HTML, {
96
+ status: 200,
97
+ headers: { "Content-Type": "text/html" },
98
+ });
99
+ }
100
+ return new Response("Not Found", { status: 404 });
101
+ },
102
+ );
103
+
104
+ const addr = this.server.addr as Deno.NetAddr;
105
+ return { port: addr.port, codePromise };
106
+ }
107
+
108
+ async close(): Promise<void> {
109
+ if (this.timerId) {
110
+ clearTimeout(this.timerId);
111
+ this.timerId = null;
112
+ }
113
+ if (this.closeTimerId) {
114
+ clearTimeout(this.closeTimerId);
115
+ this.closeTimerId = null;
116
+ }
117
+ if (this.abortController) {
118
+ this.abortController.abort();
119
+ this.abortController = null;
120
+ }
121
+ if (this.server) {
122
+ try {
123
+ await this.server.finished;
124
+ } catch {
125
+ // Expected when aborted
126
+ }
127
+ this.server = null;
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Sugar helper for preparing an authenticated MCP transport.
3
+ *
4
+ * Wires together OAuthClientProviderImpl with a CallbackServer.
5
+ * The consumer is responsible for creating the StreamableHTTPClientTransport
6
+ * and passing `result.provider` as the `authProvider` option.
7
+ *
8
+ * @module lib/server/client-auth/connect
9
+ */
10
+
11
+ import { OAuthClientProviderImpl } from "./provider.js";
12
+ import type { OAuthClientConfig } from "./types.js";
13
+ import { CallbackServer } from "./callback-server.js";
14
+
15
+ export interface PrepareOAuthResult {
16
+ /** The configured provider — pass as authProvider to StreamableHTTPClientTransport */
17
+ provider: OAuthClientProviderImpl;
18
+ /** The callback server instance (caller must close on error paths) */
19
+ callbackServer: CallbackServer;
20
+ /** The actual port the callback server is listening on */
21
+ callbackPort: number;
22
+ }
23
+
24
+ /**
25
+ * Prepare an OAuth client provider with a running callback server.
26
+ *
27
+ * Usage:
28
+ * ```typescript
29
+ * const { provider, callbackServer } = await prepareOAuthProvider(serverUrl, config);
30
+ * const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { authProvider: provider });
31
+ * await client.connect(transport);
32
+ * ```
33
+ */
34
+ export async function prepareOAuthProvider(
35
+ serverUrl: string,
36
+ config: OAuthClientConfig,
37
+ ): Promise<PrepareOAuthResult> {
38
+ const callbackServer = new CallbackServer({
39
+ port: config.callbackPort ?? 0,
40
+ timeout: config.authTimeout ?? 120_000,
41
+ });
42
+ const { port } = await callbackServer.start();
43
+
44
+ const provider = new OAuthClientProviderImpl(serverUrl, config);
45
+ provider.setRedirectUrl(`http://localhost:${port}/callback`);
46
+
47
+ return { provider, callbackServer, callbackPort: port };
48
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * OAuth client provider implementation.
3
+ *
4
+ * Implements OAuthClientProvider from the MCP SDK for authenticating
5
+ * against OAuth-protected MCP servers. Handles token storage,
6
+ * PKCE flow, and browser-based authorization.
7
+ *
8
+ * @module lib/server/client-auth/provider
9
+ */
10
+
11
+ import type {
12
+ OAuthClientInformationMixed,
13
+ OAuthClientMetadata,
14
+ OAuthTokens,
15
+ } from "@modelcontextprotocol/sdk/shared/auth.js";
16
+ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
17
+ import type { OAuthClientConfig } from "./types.js";
18
+
19
+ export class OAuthClientProviderImpl implements OAuthClientProvider {
20
+ private serverUrl: string;
21
+ private config: OAuthClientConfig;
22
+ private _codeVerifier = "";
23
+ private _clientInfo: OAuthClientInformationMixed | undefined;
24
+ private _redirectUrl: string | undefined;
25
+
26
+ constructor(serverUrl: string, config: OAuthClientConfig) {
27
+ this.serverUrl = serverUrl;
28
+ this.config = config;
29
+ }
30
+
31
+ get redirectUrl(): string | URL {
32
+ return this._redirectUrl ?? "http://localhost:0/callback";
33
+ }
34
+
35
+ /** Set the redirect URL (called after CallbackServer binds to a port). */
36
+ setRedirectUrl(url: string): void {
37
+ this._redirectUrl = url;
38
+ }
39
+
40
+ get clientMetadata(): OAuthClientMetadata {
41
+ return {
42
+ client_name: this.config.clientName ?? "PML Client",
43
+ redirect_uris: [String(this.redirectUrl)],
44
+ grant_types: ["authorization_code"],
45
+ response_types: ["code"],
46
+ token_endpoint_auth_method: "none", // public client, PKCE only
47
+ };
48
+ }
49
+
50
+ async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
51
+ return this._clientInfo ?? {
52
+ client_id: this.config.clientId,
53
+ };
54
+ }
55
+
56
+ async saveClientInformation(
57
+ info: OAuthClientInformationMixed,
58
+ ): Promise<void> {
59
+ this._clientInfo = info;
60
+ }
61
+
62
+ async tokens(): Promise<OAuthTokens | undefined> {
63
+ const stored = await this.config.tokenStore.get(this.serverUrl);
64
+ return stored?.tokens ?? undefined;
65
+ }
66
+
67
+ async saveTokens(tokens: OAuthTokens): Promise<void> {
68
+ await this.config.tokenStore.set(this.serverUrl, {
69
+ serverUrl: this.serverUrl,
70
+ tokens,
71
+ obtainedAt: Date.now(),
72
+ });
73
+ }
74
+
75
+ async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
76
+ await this.config.openBrowser(authorizationUrl.toString());
77
+ }
78
+
79
+ async saveCodeVerifier(codeVerifier: string): Promise<void> {
80
+ this._codeVerifier = codeVerifier;
81
+ }
82
+
83
+ async codeVerifier(): Promise<string> {
84
+ return this._codeVerifier;
85
+ }
86
+
87
+ async invalidateCredentials(
88
+ scope: "all" | "client" | "tokens" | "verifier" | "discovery",
89
+ ): Promise<void> {
90
+ if (scope === "all" || scope === "tokens") {
91
+ await this.config.tokenStore.delete(this.serverUrl);
92
+ }
93
+ if (scope === "all" || scope === "verifier") {
94
+ this._codeVerifier = "";
95
+ }
96
+ if (scope === "all" || scope === "client") {
97
+ this._clientInfo = undefined;
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * File-based token store.
3
+ *
4
+ * Stores OAuth credentials as JSON files with restrictive permissions (0o600).
5
+ * One file per MCP server, keyed by SHA-256 hash of the server URL.
6
+ *
7
+ * @module lib/server/client-auth/token-store/file-store
8
+ */
9
+
10
+ import type { StoredCredentials, TokenStore } from "../types.js";
11
+
12
+ async function sha256hex(input: string): Promise<string> {
13
+ const data = new TextEncoder().encode(input);
14
+ const hash = await crypto.subtle.digest("SHA-256", data);
15
+ return Array.from(new Uint8Array(hash))
16
+ .map((b) => b.toString(16).padStart(2, "0"))
17
+ .join("");
18
+ }
19
+
20
+ export class FileTokenStore implements TokenStore {
21
+ constructor(private baseDir: string) {}
22
+
23
+ private async filePath(serverUrl: string): Promise<string> {
24
+ const hash = await sha256hex(serverUrl);
25
+ return `${this.baseDir}/${hash}.json`;
26
+ }
27
+
28
+ private async ensureDir(): Promise<void> {
29
+ await Deno.mkdir(this.baseDir, { recursive: true, mode: 0o700 });
30
+ }
31
+
32
+ async get(serverUrl: string): Promise<StoredCredentials | null> {
33
+ const path = await this.filePath(serverUrl);
34
+ try {
35
+ const content = await Deno.readTextFile(path);
36
+ return JSON.parse(content) as StoredCredentials;
37
+ } catch (e) {
38
+ if (e instanceof Deno.errors.NotFound) return null;
39
+ throw e;
40
+ }
41
+ }
42
+
43
+ async set(serverUrl: string, credentials: StoredCredentials): Promise<void> {
44
+ await this.ensureDir();
45
+ const path = await this.filePath(serverUrl);
46
+ const content = JSON.stringify(credentials, null, 2);
47
+ await Deno.writeTextFile(path, content, { mode: 0o600 });
48
+ }
49
+
50
+ async delete(serverUrl: string): Promise<void> {
51
+ const path = await this.filePath(serverUrl);
52
+ try {
53
+ await Deno.remove(path);
54
+ } catch (e) {
55
+ if (e instanceof Deno.errors.NotFound) return;
56
+ throw e;
57
+ }
58
+ }
59
+
60
+ async list(): Promise<string[]> {
61
+ try {
62
+ const urls: string[] = [];
63
+ for await (const entry of Deno.readDir(this.baseDir)) {
64
+ if (entry.isFile && entry.name.endsWith(".json")) {
65
+ try {
66
+ const content = await Deno.readTextFile(
67
+ `${this.baseDir}/${entry.name}`,
68
+ );
69
+ const creds = JSON.parse(content) as StoredCredentials;
70
+ urls.push(creds.serverUrl);
71
+ } catch {
72
+ // Corrupted file, skip
73
+ }
74
+ }
75
+ }
76
+ return urls;
77
+ } catch (e) {
78
+ if (e instanceof Deno.errors.NotFound) return [];
79
+ throw e;
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * In-memory token store for testing and ephemeral use.
3
+ *
4
+ * @module lib/server/client-auth/token-store/memory-store
5
+ */
6
+
7
+ import type { StoredCredentials, TokenStore } from "../types.js";
8
+
9
+ export class MemoryTokenStore implements TokenStore {
10
+ private store = new Map<string, StoredCredentials>();
11
+
12
+ async get(serverUrl: string): Promise<StoredCredentials | null> {
13
+ return this.store.get(serverUrl) ?? null;
14
+ }
15
+
16
+ async set(serverUrl: string, credentials: StoredCredentials): Promise<void> {
17
+ this.store.set(serverUrl, credentials);
18
+ }
19
+
20
+ async delete(serverUrl: string): Promise<void> {
21
+ this.store.delete(serverUrl);
22
+ }
23
+
24
+ async list(): Promise<string[]> {
25
+ return [...this.store.keys()];
26
+ }
27
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * OAuth client auth types.
3
+ *
4
+ * Interfaces for token storage and OAuth client configuration.
5
+ * Used by OAuthClientProviderImpl to authenticate against
6
+ * OAuth-protected MCP servers.
7
+ *
8
+ * @module lib/server/client-auth/types
9
+ */
10
+
11
+ import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
12
+
13
+ /** Stored OAuth credentials for one MCP server */
14
+ export interface StoredCredentials {
15
+ /** The MCP server URL these credentials are for */
16
+ serverUrl: string;
17
+ /** OAuth tokens (access_token, refresh_token, etc.) */
18
+ tokens: OAuthTokens;
19
+ /** When the tokens were obtained (Unix epoch ms) */
20
+ obtainedAt: number;
21
+ /** Authorization server URL (needed for refresh) */
22
+ authServerUrl?: string;
23
+ }
24
+
25
+ /** Abstract token storage — consumers provide the implementation */
26
+ export interface TokenStore {
27
+ /** Retrieve stored credentials for a server, or null if none */
28
+ get(serverUrl: string): Promise<StoredCredentials | null>;
29
+ /** Store credentials for a server */
30
+ set(serverUrl: string, credentials: StoredCredentials): Promise<void>;
31
+ /** Delete credentials for a server */
32
+ delete(serverUrl: string): Promise<void>;
33
+ /** List all server URLs with stored credentials */
34
+ list(): Promise<string[]>;
35
+ }
36
+
37
+ /** Configuration for creating an OAuthClientProvider */
38
+ export interface OAuthClientConfig {
39
+ /** OAuth Client ID (public — embedded in consumer binary) */
40
+ clientId: string;
41
+ /** Client name shown in consent screen */
42
+ clientName?: string;
43
+ /** Scopes to request (space-separated in OAuth, array here) */
44
+ scopes?: string[];
45
+ /** Token storage backend */
46
+ tokenStore: TokenStore;
47
+ /** Callback to open browser — injected by consumer (platform-specific) */
48
+ openBrowser: (url: string) => Promise<void>;
49
+ /** Callback server port (0 = OS auto-assign, default: 0) */
50
+ callbackPort?: number;
51
+ /** Timeout for user to complete OAuth flow (ms, default: 120_000) */
52
+ authTimeout?: number;
53
+ }
@@ -56,6 +56,8 @@ import type {
56
56
  ToolHandler,
57
57
  } from "./types.js";
58
58
  import { MCP_APP_MIME_TYPE, MCP_APP_URI_SCHEME } from "./types.js";
59
+ import { resolveViewerDistPath, discoverViewers } from "./ui/viewer-utils.js";
60
+ import type { DirEntry, DiscoverViewersFS } from "./ui/viewer-utils.js";
59
61
  import { buildCspHeader, injectCspMetaTag } from "./security/csp.js";
60
62
  import { ServerMetrics } from "./observability/metrics.js";
61
63
  import { endToolCallSpan, startToolCallSpan } from "./observability/otel.js";
@@ -802,6 +804,75 @@ export class ConcurrentMCPServer {
802
804
  this.log(`Registered ${resources.length} resources`);
803
805
  }
804
806
 
807
+ /**
808
+ * Register MCP Apps viewers with automatic dist path resolution.
809
+ *
810
+ * Replaces the manual pattern of: enumerate viewers → resolve paths → register resources.
811
+ * Each viewer gets a `ui://{prefix}/{viewerName}` resource URI.
812
+ *
813
+ * Viewers whose dist is not found are skipped with a warning (not an error),
814
+ * so that the server can start in dev without building UIs first.
815
+ *
816
+ * @returns Summary of registered and skipped viewers
817
+ */
818
+ registerViewers(config: RegisterViewersConfig): RegisterViewersSummary {
819
+ if (!config.prefix) {
820
+ throw new Error("[ConcurrentMCPServer] registerViewers: prefix is required");
821
+ }
822
+
823
+ // Resolve viewer list: explicit or auto-discovered
824
+ let viewerNames: string[];
825
+ if (config.viewers) {
826
+ viewerNames = config.viewers;
827
+ } else if (config.discover) {
828
+ viewerNames = discoverViewers(config.discover.uiDir, config.discover);
829
+ } else {
830
+ viewerNames = [];
831
+ }
832
+
833
+ const humanNameFn = config.humanName ?? defaultHumanName;
834
+ const registered: string[] = [];
835
+ const skipped: string[] = [];
836
+
837
+ for (const viewerName of viewerNames) {
838
+ const distPath = resolveViewerDistPath(config.moduleUrl, viewerName, config.exists);
839
+
840
+ if (!distPath) {
841
+ this.log(
842
+ `Warning: UI not built for ui://${config.prefix}/${viewerName}. ` +
843
+ `Run the UI build step first.`,
844
+ );
845
+ skipped.push(viewerName);
846
+ continue;
847
+ }
848
+
849
+ const resourceUri = `ui://${config.prefix}/${viewerName}`;
850
+ const readFile = config.readFile;
851
+ const currentDistPath = distPath;
852
+
853
+ this.registerResource(
854
+ {
855
+ uri: resourceUri,
856
+ name: humanNameFn(viewerName),
857
+ description: `MCP App: ${viewerName}`,
858
+ mimeType: MCP_APP_MIME_TYPE,
859
+ },
860
+ async () => {
861
+ const html = await Promise.resolve(readFile(currentDistPath));
862
+ return { uri: resourceUri, mimeType: MCP_APP_MIME_TYPE, text: html };
863
+ },
864
+ );
865
+
866
+ registered.push(viewerName);
867
+ }
868
+
869
+ if (registered.length > 0) {
870
+ this.log(`Registered ${registered.length} viewer(s): ${registered.join(", ")}`);
871
+ }
872
+
873
+ return { registered, skipped };
874
+ }
875
+
805
876
  /**
806
877
  * Start the MCP server with stdio transport
807
878
  */
@@ -1548,6 +1619,24 @@ export class ConcurrentMCPServer {
1548
1619
  }
1549
1620
  }
1550
1621
 
1622
+ // MCP protocol methods we don't implement — return empty results
1623
+ if (method === "ping") {
1624
+ return c.json({ jsonrpc: "2.0", id, result: {} });
1625
+ }
1626
+ if (method === "prompts/list") {
1627
+ return c.json({ jsonrpc: "2.0", id, result: { prompts: [] } });
1628
+ }
1629
+ if (method === "logging/setLevel") {
1630
+ return c.json({ jsonrpc: "2.0", id, result: {} });
1631
+ }
1632
+ if (method === "completion/complete") {
1633
+ return c.json({
1634
+ jsonrpc: "2.0",
1635
+ id,
1636
+ result: { completion: { values: [] } },
1637
+ });
1638
+ }
1639
+
1551
1640
  // Handle notifications: must have a method and no id (JSON-RPC 2.0 notification)
1552
1641
  if (method && !id) {
1553
1642
  return new Response(null, { status: 202 });
@@ -1897,3 +1986,39 @@ export class ConcurrentMCPServer {
1897
1986
  }
1898
1987
  }
1899
1988
  }
1989
+
1990
+ // ── registerViewers types ──────────────────────────────────────────
1991
+
1992
+ /** Configuration for registerViewers() */
1993
+ export interface RegisterViewersConfig {
1994
+ /** URI prefix — viewers get `ui://{prefix}/{viewerName}` */
1995
+ prefix: string;
1996
+ /** import.meta.url of the consumer's server.ts (for resolving dist paths) */
1997
+ moduleUrl: string;
1998
+ /** Check if a filesystem path exists */
1999
+ exists: (path: string) => boolean;
2000
+ /** Read a file and return its content as string */
2001
+ readFile: (path: string) => string | Promise<string>;
2002
+ /** Explicit list of viewer names. If omitted, use `discover`. */
2003
+ viewers?: string[];
2004
+ /** Auto-discover viewers by scanning a directory */
2005
+ discover?: {
2006
+ uiDir: string;
2007
+ } & DiscoverViewersFS;
2008
+ /** Custom function to generate human-readable names. Default: kebab-to-Title. */
2009
+ humanName?: (viewerName: string) => string;
2010
+ }
2011
+
2012
+ /** Summary returned by registerViewers() */
2013
+ export interface RegisterViewersSummary {
2014
+ registered: string[];
2015
+ skipped: string[];
2016
+ }
2017
+
2018
+ /** Default: "invoice-viewer" → "Invoice Viewer" */
2019
+ function defaultHumanName(name: string): string {
2020
+ return name
2021
+ .split("-")
2022
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
2023
+ .join(" ");
2024
+ }
package/src/types.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  * @module lib/server/types
8
8
  */
9
9
 
10
+ import type { McpUiToolMeta as McpUiToolMetaBase } from "@casys/mcp-compose/core";
11
+
10
12
  /**
11
13
  * Rate limit configuration
12
14
  */
@@ -122,6 +124,10 @@ export interface ConcurrentServerOptions {
122
124
  /**
123
125
  * MCP Apps UI metadata for tools (SEP-1865 + PML extensions)
124
126
  *
127
+ * Extends the base `McpUiToolMeta` from mcp-compose/core with
128
+ * PML-specific `emits`/`accepts` fields for cross-UI sync rules.
129
+ * The server DECLARES capabilities; mcp-compose OWNS the contract.
130
+ *
125
131
  * @example
126
132
  * ```typescript
127
133
  * const tool: MCPTool = {
@@ -138,21 +144,14 @@ export interface ConcurrentServerOptions {
138
144
  * };
139
145
  * ```
140
146
  */
141
- export interface McpUiToolMeta {
147
+ export interface McpUiToolMeta extends McpUiToolMetaBase {
142
148
  /**
143
149
  * Resource URI for the UI. MUST use ui:// scheme.
150
+ * Narrows the optional base field to required for server tools.
144
151
  * @example "ui://mcp-std/table-viewer"
145
152
  */
146
153
  resourceUri: string;
147
154
 
148
- /**
149
- * Visibility control: who can see/call this tool
150
- * - "model": Only the AI model can see/call
151
- * - "app": Only the UI app can call (hidden from model)
152
- * - Default (both): Visible to model and app
153
- */
154
- visibility?: Array<"model" | "app">;
155
-
156
155
  /**
157
156
  * Events this UI can emit (PML extension for sync rules)
158
157
  * Used by PML orchestrator to build cross-UI event routing
@@ -0,0 +1,95 @@
1
+ /**
2
+ * MCP Apps Viewer Utilities
3
+ *
4
+ * Shared functions for resolving viewer dist paths and discovering
5
+ * viewer directories. Used by registerViewers() and the build pipeline.
6
+ *
7
+ * @module lib/server/src/ui/viewer-utils
8
+ */
9
+
10
+ /** Directories to skip during auto-discovery */
11
+ const SKIP_DIRS = new Set(["shared", "dist", "node_modules", ".cache", ".vite"]);
12
+
13
+ /**
14
+ * Convert a file:// URL to a filesystem path.
15
+ * Handles Windows drive letters and UNC paths.
16
+ */
17
+ function fileUrlToPath(url: URL): string {
18
+ const decoded = decodeURIComponent(url.pathname);
19
+ // Windows: /C:/path → C:/path
20
+ if (/^\/[A-Za-z]:\//.test(decoded)) return decoded.slice(1);
21
+ // UNC: //host/share
22
+ if (url.host.length > 0) return `//${url.host}${decoded}`;
23
+ return decoded;
24
+ }
25
+
26
+ /**
27
+ * Resolve the dist path for a viewer's built index.html.
28
+ *
29
+ * Checks two candidate locations relative to the module URL:
30
+ * 1. ./src/ui/dist/{viewerName}/index.html (Deno dev)
31
+ * 2. ./ui-dist/{viewerName}/index.html (npm package)
32
+ *
33
+ * @param moduleUrl - import.meta.url of the consumer's server.ts
34
+ * @param viewerName - Viewer directory name (e.g. "invoice-viewer")
35
+ * @param exists - Function to check if a path exists (injectable for tests)
36
+ * @returns Absolute path to index.html, or null if not found
37
+ */
38
+ export function resolveViewerDistPath(
39
+ moduleUrl: string,
40
+ viewerName: string,
41
+ exists: (path: string) => boolean,
42
+ ): string | null {
43
+ const candidates = [
44
+ fileUrlToPath(new URL(`./src/ui/dist/${viewerName}/index.html`, moduleUrl)),
45
+ fileUrlToPath(new URL(`./ui-dist/${viewerName}/index.html`, moduleUrl)),
46
+ ];
47
+
48
+ for (const candidate of candidates) {
49
+ if (exists(candidate)) return candidate;
50
+ }
51
+
52
+ return null;
53
+ }
54
+
55
+ /** Minimal entry returned by a directory reader */
56
+ export interface DirEntry {
57
+ name: string;
58
+ isDirectory: boolean;
59
+ }
60
+
61
+ /** Injectable filesystem operations for discoverViewers */
62
+ export interface DiscoverViewersFS {
63
+ readDir: (path: string) => DirEntry[];
64
+ hasIndexHtml: (uiDir: string, viewerName: string) => boolean;
65
+ }
66
+
67
+ /**
68
+ * Auto-discover viewer directories inside a UI root folder.
69
+ *
70
+ * A viewer is a directory that:
71
+ * - Is not in the skip list (shared, dist, node_modules, .cache, .vite)
72
+ * - Does not start with "."
73
+ * - Contains an index.html file
74
+ *
75
+ * @param uiDir - Absolute path to the UI root directory
76
+ * @param fs - Injectable filesystem operations
77
+ * @returns Sorted array of viewer names
78
+ */
79
+ export function discoverViewers(
80
+ uiDir: string,
81
+ fs: DiscoverViewersFS,
82
+ ): string[] {
83
+ const entries = fs.readDir(uiDir);
84
+ const viewers: string[] = [];
85
+
86
+ for (const entry of entries) {
87
+ if (!entry.isDirectory) continue;
88
+ if (entry.name.startsWith(".")) continue;
89
+ if (SKIP_DIRS.has(entry.name)) continue;
90
+ if (!fs.hasIndexHtml(uiDir, entry.name)) continue;
91
+ viewers.push(entry.name);
92
+ }
93
+
94
+ return viewers.sort();
95
+ }