@ultimat3/mcp 22.13.0 → 22.14.0

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/README.md CHANGED
@@ -130,6 +130,13 @@ to mount — no file exports `mcp`, or the export was built without `resolveToke
130
130
  `route` — logs `X_MCP_APP_UNMOUNTED` once, with the file to write; `expose: false` mounts nothing
131
131
  and says nothing.
132
132
 
133
+ **Several endpoints, one per population (Unreleased).** `mcp` may be a non-empty array of
134
+ `defineAppMcp` values: endpoint #0 mounts at `config.ai.mcp.path` and owns the root well-known
135
+ document; every other mounts at its own `defineAppMcp({ path })` and serves only its path-inserted
136
+ metadata document. Each is its own server — catalog, instructions, groups, scopes, prompts. Two on
137
+ one route throws `X_MCP_PATH_DUPLICATE` at boot. `McpPrompt.visibleTo` hides a prompt from
138
+ `prompts/list` and `prompts/get` with the tool/resource `McpVisibility` semantics.
139
+
133
140
  `include: 'exposed'` reads the action and query registries instead of asking for
134
141
  `actions: [...]` / `queries: [...]` — the registries already know who opted in, and a
135
142
  second hand-maintained list is a thing that goes stale silently. The explicit arrays still
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mcp",
3
- "version": "22.13.0",
3
+ "version": "22.14.0",
4
4
  "description": "MCP server, dev tools, and the action-to-tool projection \u2014 one authz system, two surfaces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,13 +31,13 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/action": "22.13.0",
35
- "@ultimat3/core": "22.13.0",
36
- "@ultimat3/entity": "22.13.0",
37
- "@ultimat3/http": "22.13.0",
38
- "@ultimat3/jobs": "22.13.0",
39
- "@ultimat3/policy": "22.13.0",
40
- "@ultimat3/query": "22.13.0",
41
- "@ultimat3/schema": "22.13.0"
34
+ "@ultimat3/action": "22.14.0",
35
+ "@ultimat3/core": "22.14.0",
36
+ "@ultimat3/entity": "22.14.0",
37
+ "@ultimat3/http": "22.14.0",
38
+ "@ultimat3/jobs": "22.14.0",
39
+ "@ultimat3/policy": "22.14.0",
40
+ "@ultimat3/query": "22.14.0",
41
+ "@ultimat3/schema": "22.14.0"
42
42
  }
43
43
  }
package/src/errors.ts CHANGED
@@ -26,6 +26,7 @@ export const MCP_ERROR_CODES = [
26
26
  'X_MCP_OAUTH_INVALID',
27
27
  'X_MCP_IDEMPOTENCY_KEY_SHADOWED',
28
28
  'X_MCP_SURFACE_OVER_BUDGET',
29
+ 'X_MCP_PATH_DUPLICATE',
29
30
  ] as const;
30
31
 
31
32
  export type McpErrorCode = (typeof MCP_ERROR_CODES)[number];
@@ -54,6 +55,7 @@ export const MCP_ERROR_TITLES: Readonly<Record<McpErrorCode, string>> = {
54
55
  X_MCP_IDEMPOTENCY_KEY_SHADOWED:
55
56
  "an idempotent action's input declares idempotencyKey, the MCP argument reserved for the idempotency key",
56
57
  X_MCP_SURFACE_OVER_BUDGET: "an MCP surface an agent reads is larger than the app's budget for it",
58
+ X_MCP_PATH_DUPLICATE: 'two MCP endpoints claim one path',
57
59
  };
58
60
 
59
61
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -197,17 +199,26 @@ export class McpToolUndeclaredError extends UltimateError {
197
199
  */
198
200
  export class McpAppUnmountedError extends UltimateError {
199
201
  readonly reason: 'missing' | 'no-route';
200
- constructor(input: { reason: 'missing' | 'no-route'; path: string; file: string }) {
202
+ constructor(input: {
203
+ reason: 'missing' | 'no-route';
204
+ path: string;
205
+ file: string;
206
+ /** Index in an `mcp` array export; absent for a single export or the first endpoint. */
207
+ endpoint?: number;
208
+ }) {
209
+ const where =
210
+ input.endpoint === undefined ? input.file : `${input.file} (mcp[${input.endpoint}])`;
211
+ const route = input.endpoint === undefined ? `POST ${input.path}` : 'its endpoint';
201
212
  super({
202
213
  code: 'X_MCP_APP_UNMOUNTED',
203
214
  cause:
204
215
  input.reason === 'missing'
205
216
  ? `ai.mcp.expose is true and no ${input.file} exports mcp, so POST ${input.path} answers 404`
206
- : `${input.file} exports mcp with no route — defineAppMcp was given no resolveToken — so POST ${input.path} answers 404`,
217
+ : `${where} exports mcp with no route — defineAppMcp was given no resolveToken — so ${route} answers 404`,
207
218
  fix:
208
219
  input.reason === 'missing'
209
220
  ? `write ${input.file}: export const mcp = defineAppMcp({ include: 'exposed', resolveToken: (token) => resolveAgentToken(token) }) — or set ai: { mcp: { expose: false } } in app.config.ts`
210
- : `add resolveToken: (token) => resolveAgentToken(token) to defineAppMcp({ ... }) in ${input.file} — the route is only built with one`,
221
+ : `add resolveToken: (token) => resolveAgentToken(token) to the defineAppMcp({ ... }) at ${where} — the route is only built with one`,
211
222
  });
212
223
  this.reason = input.reason;
213
224
  }
package/src/index.ts CHANGED
@@ -117,6 +117,7 @@ export {
117
117
  oneLineParams,
118
118
  renderCatalog,
119
119
  } from './meta-surface';
120
+ export { McpPathDuplicateError } from './mount-errors';
120
121
  export type { McpOAuth } from './oauth-metadata';
121
122
  export {
122
123
  metadataPaths,
@@ -0,0 +1,31 @@
1
+ // The boot-time refusal of an app's MCP MOUNT: several `defineAppMcp` endpoints, one path each.
2
+ // The code and title are registered in `errors.ts` with the rest of the package's; the class sits
3
+ // apart only so that file stays under the size ceiling (the `meta-errors.ts` precedent).
4
+
5
+ import { UltimateError } from '@ultimat3/core';
6
+
7
+ /**
8
+ * Two endpoints resolved to one route. Thrown, never warned: the route table would keep whichever
9
+ * came first, and the population behind the second would be answered with the first's catalog,
10
+ * instructions and scopes — silently, and only for the URL both claimed.
11
+ */
12
+ export class McpPathDuplicateError extends UltimateError {
13
+ readonly path: string;
14
+ readonly endpoints: readonly [number, number];
15
+
16
+ constructor(input: {
17
+ method: string;
18
+ path: string;
19
+ endpoints: readonly [number, number];
20
+ file: string;
21
+ }) {
22
+ const [first, second] = input.endpoints;
23
+ super({
24
+ code: 'X_MCP_PATH_DUPLICATE',
25
+ cause: `${input.file} exports mcp endpoints #${first} and #${second}, and both claim ${input.method} ${input.path}`,
26
+ fix: `give endpoint #${second} in ${input.file} its own path — defineAppMcp({ path: '/mcp/<population>', ... }); endpoint #0 mounts at ai.mcp.path and every other at its own defineAppMcp path`,
27
+ });
28
+ this.path = input.path;
29
+ this.endpoints = input.endpoints;
30
+ }
31
+ }
@@ -2,6 +2,8 @@
2
2
  // advertises the `prompts` capability, so a listed prompt that `prompts/get` cannot resolve is a
3
3
  // capability the server claims and does not have.
4
4
 
5
+ import type { McpCaller } from './registry';
6
+ import { visibleToCaller } from './registry';
5
7
  import type { McpPrompt } from './resources';
6
8
  import type { JsonRpcId, JsonRpcResponse } from './wire';
7
9
  import { errorResponse, INTERNAL_ERROR, INVALID_PARAMS, resultResponse } from './wire';
@@ -13,6 +15,15 @@ export const promptListEntry = (prompt: McpPrompt) => ({
13
15
  ...(prompt.arguments === undefined ? {} : { arguments: prompt.arguments }),
14
16
  });
15
17
 
18
+ /**
19
+ * The prompts THIS caller may see — the one filter both methods read, so `prompts/list` and
20
+ * `prompts/get` cannot disagree about a prompt's existence.
21
+ */
22
+ export const visiblePrompts = (
23
+ prompts: readonly McpPrompt[],
24
+ caller: McpCaller,
25
+ ): readonly McpPrompt[] => prompts.filter((prompt) => visibleToCaller(prompt, caller));
26
+
16
27
  /** Only string arguments: MCP types every prompt argument as a string. */
17
28
  const stringArguments = (raw: unknown): Readonly<Record<string, string>> => {
18
29
  if (typeof raw !== 'object' || raw === null) return {};
@@ -25,12 +36,15 @@ export async function promptsGet(
25
36
  prompts: readonly McpPrompt[],
26
37
  id: JsonRpcId,
27
38
  params: Readonly<Record<string, unknown>> | null,
39
+ caller: McpCaller,
28
40
  ): Promise<JsonRpcResponse> {
29
41
  const name = params?.['name'];
30
42
  if (typeof name !== 'string') {
31
43
  return errorResponse(id, INVALID_PARAMS, 'prompts/get params.name must be a string');
32
44
  }
33
- const prompt = prompts.find((declared) => declared.name === name);
45
+ // An invisible prompt answers exactly as a missing one: a distinct refusal would tell a customer
46
+ // the staff prompt exists, which is the one fact `visibleTo` withholds.
47
+ const prompt = visiblePrompts(prompts, caller).find((declared) => declared.name === name);
34
48
  if (prompt === undefined) {
35
49
  return errorResponse(id, INVALID_PARAMS, `prompt not found: ${name} — read prompts/list`);
36
50
  }
package/src/resources.ts CHANGED
@@ -61,6 +61,12 @@ export interface McpPrompt {
61
61
  readonly name: string;
62
62
  readonly description: string;
63
63
  readonly arguments?: readonly McpPromptArgument[];
64
+ /**
65
+ * Who may see and get it — OUTCOME 1. Absent = everyone. Same `McpVisibility` as a tool's and a
66
+ * resource's, evaluated by the same fail-closed `visibleToCaller`: a prompt that names a staff
67
+ * workflow is part of the staff surface, and listing it to a customer leaks the workflow.
68
+ */
69
+ readonly visibleTo?: McpVisibility;
64
70
  /**
65
71
  * The prompt's TEXT, for `prompts/get`, given the arguments the client passed. An injected thunk
66
72
  * — the `frameworkResources` pattern — so the server never learns where a prompt lives.
package/src/server.ts CHANGED
@@ -12,7 +12,7 @@ import { metaCall } from './meta-call';
12
12
  import { META_UNKNOWN_FIX } from './meta-errors';
13
13
  import type { McpResourceGroups, McpSurfaceOption, MetaResource } from './meta-surface';
14
14
  import { MANAGE_RESOURCE, META_TOOL_ENTRIES, META_TOOL_NAMES, MetaSurface } from './meta-surface';
15
- import { promptListEntry, promptsGet } from './prompts-get';
15
+ import { promptListEntry, promptsGet, visiblePrompts } from './prompts-get';
16
16
  import type {
17
17
  AnyMcpTool,
18
18
  McpCaller,
@@ -198,9 +198,11 @@ export class McpServer {
198
198
  case 'ping':
199
199
  return resultResponse(id, {});
200
200
  case 'prompts/list':
201
- return resultResponse(id, { prompts: this.prompts.map(promptListEntry) });
201
+ return resultResponse(id, {
202
+ prompts: visiblePrompts(this.prompts, caller).map(promptListEntry),
203
+ });
202
204
  case 'prompts/get':
203
- return promptsGet(this.prompts, id, paramsOf(body));
205
+ return promptsGet(this.prompts, id, paramsOf(body), caller);
204
206
  default:
205
207
  return errorResponse(id, METHOD_NOT_FOUND, `method not found: ${body.method}`, {
206
208
  supported: METHODS,