mcp-tenant-lib 0.3.5 → 0.3.6

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.
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { tenants, getOrCreateTenant, isValidChannelName } from './tenant.js';
3
3
  import { findChannelMatches } from './channel-search.js';
4
+ import { buildDescribePayload } from './manifest-tools.js';
4
5
  /**
5
6
  * Registers `join_channel` and `list_channels` on an MCP server built via
6
7
  * buildMcpServer. Shared across every mcp-tenant-lib consumer (mcp-form,
@@ -18,7 +19,8 @@ export function registerChannelTools(mcp, tenant, port, setChannel, initialSchem
18
19
  'anonymous "default" channel, visible to every other unnamed session on this server — skip only when the ' +
19
20
  'user says it\'s a one-off/throwaway, or nothing suggests a distinct topic worth naming. Reusing an ' +
20
21
  'existing name is expected, not an error: it retargets this session onto that channel\'s live state (e.g. ' +
21
- 'to resume, or redefine/refresh it). Names are URL-safe slugs: letters, digits, underscore, hyphen only.', { channel: z.string().describe('Agent-chosen channel name, e.g. "pets" or "pet_questions_1_of_2". Letters/digits/underscore/hyphen only.') }, async ({ channel }) => {
22
+ 'to resume, or redefine/refresh it). Names are URL-safe slugs: letters, digits, underscore, hyphen only. ' +
23
+ 'To only inspect a channel\'s tools without retargeting this session onto it, use describe_channel instead.', { channel: z.string().describe('Agent-chosen channel name, e.g. "pets" or "pet_questions_1_of_2". Letters/digits/underscore/hyphen only.') }, async ({ channel }) => {
22
24
  if (!isValidChannelName(channel)) {
23
25
  return {
24
26
  content: [{ type: 'text', text: `Error: "${channel}" is not a valid channel name — use only letters, digits, underscore, and hyphen.` }],
@@ -32,11 +34,11 @@ export function registerChannelTools(mcp, tenant, port, setChannel, initialSchem
32
34
  };
33
35
  });
34
36
  mcp.tool('list_channels', 'Lists every channel currently live on this server, including "default" (the shared, anonymous channel ' +
35
- 'sessions land on before calling join_channel). Use this to discover an existing named channel before ' +
36
- 'calling join_channel on it, e.g. when a human refers to "the pets form" without giving the exact channel ' +
37
- 'name. Each entry includes a `connections` array — one item per live browser tab/page bridged into that ' +
38
- 'channel, with its display `label` and `toolCount` — so you can tell which channels actually have ' +
39
- 'something connected without joining each one to check.', {}, async () => {
37
+ 'sessions land on before calling join_channel). Use this to discover an existing named channel, e.g. when ' +
38
+ 'a human refers to "the pets form" without giving the exact channel name. Each entry includes a ' +
39
+ '`connections` array — one item per live browser tab/page bridged into that channel, with its display ' +
40
+ '`label` and `toolCount` — so you can tell which channels actually have something connected. To see the ' +
41
+ 'actual tools on one, call describe_channel rather than joining just to look.', {}, async () => {
40
42
  const channels = [...tenants.entries()].map(([channel, t]) => ({
41
43
  channel,
42
44
  connections: [...t.connections.values()].map((c) => ({
@@ -58,4 +60,22 @@ export function registerChannelTools(mcp, tenant, port, setChannel, initialSchem
58
60
  const matches = findChannelMatches(query, [...tenants.keys()]);
59
61
  return { content: [{ type: 'text', text: JSON.stringify(matches, null, 2) }] };
60
62
  });
63
+ mcp.tool('describe_channel', 'Returns the tool manifest for a named channel — same payload as describe_tools, but for ANY channel, ' +
64
+ 'not just the one this session is currently on. Use this to go straight from a channel name (e.g. from ' +
65
+ 'list_channels or channel_find) to what tools it has, without join_channel first retargeting this ' +
66
+ 'session\'s own state onto it. Read-only: does not join, create, or affect this session\'s current ' +
67
+ 'channel. Errors if the channel does not exist yet — check list_channels/channel_find first. If a tool ' +
68
+ 'listed here fails to invoke with "No such tool available" (typically right after the MCP server ' +
69
+ 'process was restarted), your MCP client\'s own connection is stale, not this manifest — tell the user ' +
70
+ 'to reconnect the MCP client (e.g. /mcp in Claude Code) rather than retrying the call.', { channel: z.string().describe('Exact channel name, e.g. from list_channels or channel_find.') }, async ({ channel }) => {
71
+ const t = tenants.get(channel);
72
+ if (!t) {
73
+ return {
74
+ content: [{ type: 'text', text: `Error: no channel named "${channel}" — use list_channels or channel_find to find the right name.` }],
75
+ isError: true,
76
+ };
77
+ }
78
+ const payload = buildDescribePayload(t);
79
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
80
+ });
61
81
  }
@@ -4,6 +4,33 @@ export interface ManifestToolRegistry {
4
4
  handles: Map<string, RegisteredTool>;
5
5
  sync(): void;
6
6
  }
7
+ /**
8
+ * Same shape describe_tools returns for the caller's own current channel,
9
+ * built from any Tenant — shared with describe_channel (channel-tools.ts)
10
+ * so a channel's manifest can be inspected by name without join_channel
11
+ * retargeting the session onto it first.
12
+ */
13
+ export declare function buildDescribePayload<TSchema, TValues>(t: Tenant<TSchema, TValues>): {
14
+ summary: string | null;
15
+ tools: {
16
+ name: string;
17
+ description: string;
18
+ }[];
19
+ connections?: undefined;
20
+ } | {
21
+ connections: {
22
+ id: string;
23
+ label: string | null;
24
+ toolPrefix: string | undefined;
25
+ summary: string | null;
26
+ tools: {
27
+ name: string;
28
+ description: string;
29
+ }[];
30
+ }[];
31
+ summary?: undefined;
32
+ tools?: undefined;
33
+ };
7
34
  /**
8
35
  * Registers one MCP tool per entry in tenant().toolManifest, dispatching
9
36
  * calls to the page via tenant().call(target, args). Also always registers
@@ -1,4 +1,6 @@
1
1
  import { z } from 'zod';
2
+ class UnsupportedParamTypeError extends Error {
3
+ }
2
4
  function paramSpecToZod(spec) {
3
5
  let schema;
4
6
  switch (spec.type) {
@@ -11,7 +13,7 @@ function paramSpecToZod(spec) {
11
13
  case 'boolean':
12
14
  schema = z.boolean();
13
15
  break;
14
- default: throw new Error(`unsupported param type "${spec.type}" (supported: string, number, boolean)`);
16
+ default: throw new UnsupportedParamTypeError(`unsupported param type "${spec.type}" (supported: string, number, boolean)`);
15
17
  }
16
18
  if (spec.description)
17
19
  schema = schema.describe(spec.description);
@@ -24,6 +26,34 @@ function manifestEntryToZodShape(entry) {
24
26
  }
25
27
  return shape;
26
28
  }
29
+ /**
30
+ * Same shape describe_tools returns for the caller's own current channel,
31
+ * built from any Tenant — shared with describe_channel (channel-tools.ts)
32
+ * so a channel's manifest can be inspected by name without join_channel
33
+ * retargeting the session onto it first.
34
+ */
35
+ export function buildDescribePayload(t) {
36
+ const conns = [...t.connections.values()];
37
+ if (conns.length <= 1) {
38
+ return {
39
+ summary: t.toolManifestSummary ?? null,
40
+ tools: t.toolManifest.map((e) => ({ name: e.name, description: e.description })),
41
+ };
42
+ }
43
+ const slugFor = computeSlugs(conns);
44
+ return {
45
+ connections: conns.map((c) => ({
46
+ id: c.id,
47
+ label: c.label ?? null,
48
+ toolPrefix: slugFor.get(c.id),
49
+ summary: c.summary ?? null,
50
+ tools: c.manifest.map((e) => ({
51
+ name: `${slugFor.get(c.id)}__${e.name}`,
52
+ description: e.description,
53
+ })),
54
+ })),
55
+ };
56
+ }
27
57
  const DESCRIBE_TOOLS_NAME = 'describe_tools';
28
58
  const IDENTIFY_CONNECTION_NAME = 'identify_connection';
29
59
  const IDENTIFY_CONNECTION_DESCRIPTION = 'Pops an alert in the browser tab behind one connection, so a human looking at several open tabs/windows ' +
@@ -43,7 +73,10 @@ const DESCRIBE_TOOLS_DESCRIPTION = 'Returns manifest-level context for the tools
43
73
  'IMPORTANT — an empty or unexpected result here does NOT mean no page is bridged: this session may ' +
44
74
  'simply be on the wrong channel (see join_channel). If the user expects a specific bridged app/page by ' +
45
75
  'name (e.g. "the bulletino tab") and it\'s missing, call list_channels to check for a matching channel ' +
46
- 'and join_channel to it before assuming nothing is connected.';
76
+ 'and join_channel to it before assuming nothing is connected. If a tool listed here instead fails to ' +
77
+ 'invoke with "No such tool available" (typically right after the MCP server process was restarted), ' +
78
+ 'your MCP client\'s own connection is stale, not this manifest — tell the user to reconnect the MCP ' +
79
+ 'client (e.g. /mcp in Claude Code) rather than retrying the call.';
47
80
  /**
48
81
  * Derives a stable, unique tool-name prefix per connection: sanitized from
49
82
  * `label` (falling back to "tab" when absent or empty after sanitizing),
@@ -91,28 +124,7 @@ export function createManifestToolRegistry(mcp, tenant) {
91
124
  const handles = new Map();
92
125
  function registerDescribeTools() {
93
126
  const handle = mcp.registerTool(DESCRIBE_TOOLS_NAME, { description: DESCRIBE_TOOLS_DESCRIPTION, inputSchema: {} }, async () => {
94
- const t = tenant();
95
- const conns = [...t.connections.values()];
96
- if (conns.length <= 1) {
97
- const payload = {
98
- summary: t.toolManifestSummary ?? null,
99
- tools: t.toolManifest.map((e) => ({ name: e.name, description: e.description })),
100
- };
101
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
102
- }
103
- const slugFor = computeSlugs(conns);
104
- const payload = {
105
- connections: conns.map((c) => ({
106
- id: c.id,
107
- label: c.label ?? null,
108
- toolPrefix: slugFor.get(c.id),
109
- summary: c.summary ?? null,
110
- tools: c.manifest.map((e) => ({
111
- name: `${slugFor.get(c.id)}__${e.name}`,
112
- description: e.description,
113
- })),
114
- })),
115
- };
127
+ const payload = buildDescribePayload(tenant());
116
128
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
117
129
  });
118
130
  handles.set(DESCRIBE_TOOLS_NAME, handle);
@@ -177,7 +189,22 @@ export function createManifestToolRegistry(mcp, tenant) {
177
189
  for (const [registeredName, { connectionId, entry }] of registeredNow) {
178
190
  if (handles.has(registeredName))
179
191
  continue;
180
- const handle = mcp.registerTool(registeredName, { description: entry.description, inputSchema: manifestEntryToZodShape(entry) }, async (args) => {
192
+ let inputSchema;
193
+ try {
194
+ inputSchema = manifestEntryToZodShape(entry);
195
+ }
196
+ catch (err) {
197
+ // A single page-authored tool with a malformed param spec (bad/missing
198
+ // `type`) must not take down sync() for every other tool on the
199
+ // channel, nor crash whatever triggered this sync (e.g. join_channel
200
+ // migrating the registry) — skip just this one entry.
201
+ if (err instanceof UnsupportedParamTypeError) {
202
+ console.error(`[mcp-tenant-lib] skipping tool "${registeredName}": ${err.message}`);
203
+ continue;
204
+ }
205
+ throw err;
206
+ }
207
+ const handle = mcp.registerTool(registeredName, { description: entry.description, inputSchema }, async (args) => {
181
208
  try {
182
209
  const result = await tenant().call(connectionId, entry.name, args);
183
210
  return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-tenant-lib",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "type": "module",
5
5
  "description": "Generic tenant/session bookkeeping + MCP wiring, reusable across projects.",
6
6
  "repository": {