@lanes-sh/link 0.1.0 → 0.1.2

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.
@@ -38,14 +38,49 @@ export function buildMcpServer(options: BuildServerOptions): McpServer {
38
38
  // rather than trusting a copy to stay one. It is the whole endpoint being
39
39
  // described, not this connection, so it does not name the profiles the
40
40
  // way `title` does.
41
- description: 'A self-hosted MCP gateway for your accounts, memory, skills, and secrets',
41
+ description: 'A self-hostable MCP gateway for all your connections, memory, skills, and secrets',
42
42
  websiteUrl: 'https://github.com/lanes-sh/link',
43
43
  icons: SERVER_ICONS,
44
44
  },
45
45
  // Second argument, not the first: `instructions` is a `ServerOptions` field,
46
46
  // and `Implementation` would take it as an unknown extra and drop it from
47
47
  // `initialize` without complaining.
48
- { instructions: serverInstructions(names, merged) },
48
+ {
49
+ instructions: serverInstructions(names, merged),
50
+ // Declared `false` because it is false, and the SDK defaults it to `true`.
51
+ //
52
+ // `listChanged` is a promise to send `notifications/tools/list_changed`
53
+ // when the surface changes. This endpoint cannot keep it: it is stateless
54
+ // streamable HTTP, so there is no stream to deliver a notification on and
55
+ // this very server instance is discarded once the response is written.
56
+ // Nothing in `src/` sends one, and nothing can.
57
+ //
58
+ // Leaving the default on is not a harmless inaccuracy. A client that
59
+ // believes it will be told has no reason to ask again, so it keeps the
60
+ // list it captured when it first connected — and the surface here grows
61
+ // every time an account is connected (ADR-029). That combination cost a
62
+ // connector registered before `connect` ran its whole tool list: it held
63
+ // the two setup tools for as long as it lived, and no refresh replaced
64
+ // them, because the refresh was an `initialize` that never went on to ask
65
+ // for `tools/list`.
66
+ //
67
+ // Saying `false` costs a re-list per session and buys a surface that is
68
+ // never stale.
69
+ //
70
+ // `tools` unconditionally, because the argument above is about tools and
71
+ // an endpoint that serves none should still answer "none right now"
72
+ // rather than "this server does not do tools". Resources and prompts only
73
+ // when the profile has some: declaring a capability installs its handler
74
+ // set, so a client that gates its list calls on what is advertised would
75
+ // otherwise issue `resources/list`, `resources/templates/list` and
76
+ // `prompts/list` — three stateless POSTs, each rebuilding the whole
77
+ // server — to be told nothing is there.
78
+ capabilities: {
79
+ tools: { listChanged: false },
80
+ ...(offers(merged, isResource) ? { resources: { listChanged: false } } : {}),
81
+ ...(offers(merged, isPrompt) ? { prompts: { listChanged: false } } : {}),
82
+ },
83
+ },
49
84
  );
50
85
 
51
86
  for (const [id, entry] of merged) {
@@ -66,3 +101,22 @@ export function buildMcpServer(options: BuildServerOptions): McpServer {
66
101
 
67
102
  return server;
68
103
  }
104
+
105
+ /**
106
+ * Whether any reachable capability registers as this kind.
107
+ *
108
+ * Reads the same `merged` map the registration loop below consumes and asks it
109
+ * the same question `isResource`/`isPrompt` answer there, so what is advertised
110
+ * and what is registered cannot disagree. A discovered capability is always a
111
+ * tool, which is why it is not consulted here.
112
+ */
113
+ function offers(
114
+ merged: ReturnType<typeof mergeCapabilities>,
115
+ kind: typeof isResource | typeof isPrompt,
116
+ ): boolean {
117
+ for (const entry of merged.values()) {
118
+ if (!entry.discovered && entry.capability && kind(entry.capability)) return true;
119
+ }
120
+
121
+ return false;
122
+ }
@@ -26,6 +26,7 @@ export {
26
26
  mergeCapabilities,
27
27
  oneProfile,
28
28
  visibleCapabilities,
29
+ visibleToolCount,
29
30
  type BuildServerOptions,
30
31
  type MergedCapability,
31
32
  type ProfileRuntime,
@@ -1,3 +1,4 @@
1
+ import { isTool } from '#connectivity';
1
2
  import type { Principal } from '#auth';
2
3
  import type { Config } from '#profile';
3
4
  import type { ProviderRegistry } from '#registry';
@@ -114,6 +115,27 @@ export function visibleCapabilities(options: BuildServerOptions): string[] {
114
115
  return [...mergeCapabilities(options).keys()];
115
116
  }
116
117
 
118
+ /**
119
+ * How many of those are tools, as `tools/list` would count them.
120
+ *
121
+ * Not the same number as `visibleCapabilities().length`, and the difference is
122
+ * the kind of thing that only shows up when someone reads it: a reachable
123
+ * capability may register as a resource or a prompt instead, so counting ids
124
+ * and calling the answer "tools" overstates the list by however many of those
125
+ * a profile has. The kind is decided here exactly as `buildMcpServer` decides
126
+ * it — a discovered capability is always a tool, an authored one is asked.
127
+ */
128
+ export function visibleToolCount(options: BuildServerOptions): number {
129
+ let count = 0;
130
+
131
+ for (const entry of mergeCapabilities(options).values()) {
132
+ if (entry.discovered) count += 1;
133
+ else if (entry.capability && isTool(entry.capability)) count += 1;
134
+ }
135
+
136
+ return count;
137
+ }
138
+
117
139
  /**
118
140
  * Say which accounts are reachable, grouped by profile.
119
141
  *