@lanes-sh/link 0.1.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.
Files changed (276) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +144 -0
  3. package/bin/lanes +42 -0
  4. package/instructions/agents/lanes-link-scout.md +73 -0
  5. package/instructions/skills/lanes-link/SKILL.md +187 -0
  6. package/package.json +95 -0
  7. package/src/audit/chain.ts +278 -0
  8. package/src/audit/conformance.ts +374 -0
  9. package/src/audit/fanout.ts +97 -0
  10. package/src/audit/index.ts +218 -0
  11. package/src/audit/stdout.ts +60 -0
  12. package/src/auth/index.ts +220 -0
  13. package/src/auth/oauth/metadata.ts +75 -0
  14. package/src/auth/oauth/server.ts +385 -0
  15. package/src/auth/oauth/store.ts +215 -0
  16. package/src/auth/oidc.ts +206 -0
  17. package/src/auth/remote.ts +72 -0
  18. package/src/cli/argv.ts +94 -0
  19. package/src/cli/callback-page.ts +256 -0
  20. package/src/cli/commands/connect/accounts.ts +94 -0
  21. package/src/cli/commands/connect/authorise.ts +298 -0
  22. package/src/cli/commands/connect/client.ts +284 -0
  23. package/src/cli/commands/connect/index.ts +398 -0
  24. package/src/cli/commands/connect/outcome.ts +119 -0
  25. package/src/cli/commands/connect/requirements.ts +103 -0
  26. package/src/cli/commands/connect/scopes-gate.ts +146 -0
  27. package/src/cli/commands/connect/settle.ts +136 -0
  28. package/src/cli/commands/connect/setup.ts +276 -0
  29. package/src/cli/commands/mcp/assets.ts +189 -0
  30. package/src/cli/commands/mcp/harnesses.ts +143 -0
  31. package/src/cli/commands/mcp/list.ts +70 -0
  32. package/src/cli/commands/mcp/register.ts +199 -0
  33. package/src/cli/commands/mcp/stdio.ts +57 -0
  34. package/src/cli/commands/mcp.ts +22 -0
  35. package/src/cli/commands/operate/attach.ts +121 -0
  36. package/src/cli/commands/operate/audit.ts +119 -0
  37. package/src/cli/commands/operate/inspect.ts +355 -0
  38. package/src/cli/commands/operate/outputs.ts +206 -0
  39. package/src/cli/commands/operate/policy.ts +80 -0
  40. package/src/cli/commands/operate/serve.ts +55 -0
  41. package/src/cli/commands/operate/status.ts +133 -0
  42. package/src/cli/commands/operate/token.ts +70 -0
  43. package/src/cli/commands/operate.ts +27 -0
  44. package/src/cli/commands/owner/memory.ts +110 -0
  45. package/src/cli/commands/owner/shared.ts +125 -0
  46. package/src/cli/commands/owner/skills.ts +92 -0
  47. package/src/cli/commands/owner/vault.ts +140 -0
  48. package/src/cli/commands/owner.ts +46 -0
  49. package/src/cli/commands/profile/removal.ts +278 -0
  50. package/src/cli/commands/profile/remove.ts +315 -0
  51. package/src/cli/commands/profile.ts +156 -0
  52. package/src/cli/commands/secrets.ts +176 -0
  53. package/src/cli/commands/setup.ts +150 -0
  54. package/src/cli/commands/target.ts +310 -0
  55. package/src/cli/config-edit.ts +397 -0
  56. package/src/cli/endpoint-url.ts +77 -0
  57. package/src/cli/identity.ts +109 -0
  58. package/src/cli/lanes.ts +78 -0
  59. package/src/cli/main.ts +333 -0
  60. package/src/cli/oauth-error.ts +13 -0
  61. package/src/cli/oauth-exchange.ts +146 -0
  62. package/src/cli/oauth.ts +354 -0
  63. package/src/cli/output.ts +184 -0
  64. package/src/cli/prompt.ts +180 -0
  65. package/src/cli/publish.ts +185 -0
  66. package/src/cli/runtime/discovery.ts +123 -0
  67. package/src/cli/runtime/open.ts +342 -0
  68. package/src/cli/runtime/registry.ts +185 -0
  69. package/src/cli/runtime/select.ts +124 -0
  70. package/src/cli/runtime.ts +34 -0
  71. package/src/cli/scopes.ts +63 -0
  72. package/src/cli/usage.ts +111 -0
  73. package/src/cli/version.ts +25 -0
  74. package/src/connectivity/auth/README.md +36 -0
  75. package/src/connectivity/auth/api-key/index.ts +43 -0
  76. package/src/connectivity/auth/authorize.ts +49 -0
  77. package/src/connectivity/auth/basic/index.ts +68 -0
  78. package/src/connectivity/auth/bearer/index.ts +13 -0
  79. package/src/connectivity/auth/credential.ts +19 -0
  80. package/src/connectivity/auth/header/index.ts +18 -0
  81. package/src/connectivity/auth/index.ts +35 -0
  82. package/src/connectivity/auth/none/index.ts +12 -0
  83. package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
  84. package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
  85. package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
  86. package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
  87. package/src/connectivity/auth/resolve.ts +61 -0
  88. package/src/connectivity/auth/strategy/index.ts +14 -0
  89. package/src/connectivity/capability.ts +164 -0
  90. package/src/connectivity/connector.ts +175 -0
  91. package/src/connectivity/context.ts +77 -0
  92. package/src/connectivity/index.ts +94 -0
  93. package/src/connectivity/mail/attachments.ts +368 -0
  94. package/src/connectivity/mail/compose.ts +73 -0
  95. package/src/connectivity/mail/index.ts +40 -0
  96. package/src/connectivity/mail/message.ts +82 -0
  97. package/src/connectivity/mail/nodemailer.d.ts +83 -0
  98. package/src/connectivity/mail/staging.ts +143 -0
  99. package/src/connectivity/mail/url.ts +301 -0
  100. package/src/connectivity/manifest/auth.ts +135 -0
  101. package/src/connectivity/manifest/bundles.ts +23 -0
  102. package/src/connectivity/manifest/connector.ts +168 -0
  103. package/src/connectivity/manifest/credential-ref.ts +73 -0
  104. package/src/connectivity/manifest/identity.ts +43 -0
  105. package/src/connectivity/manifest/index.ts +46 -0
  106. package/src/connectivity/manifest/primitives.ts +20 -0
  107. package/src/connectivity/manifest/provider.ts +208 -0
  108. package/src/connectivity/manifest/requirements.ts +146 -0
  109. package/src/connectivity/manifest/setup.ts +55 -0
  110. package/src/connectivity/provider.ts +163 -0
  111. package/src/connectivity/transports/README.md +33 -0
  112. package/src/connectivity/transports/composite/index.ts +68 -0
  113. package/src/connectivity/transports/dav/calendar.ts +217 -0
  114. package/src/connectivity/transports/dav/capabilities.ts +151 -0
  115. package/src/connectivity/transports/dav/client.ts +226 -0
  116. package/src/connectivity/transports/dav/contacts.ts +75 -0
  117. package/src/connectivity/transports/dav/ical.ts +412 -0
  118. package/src/connectivity/transports/dav/index.ts +143 -0
  119. package/src/connectivity/transports/dav/operations.ts +43 -0
  120. package/src/connectivity/transports/dav/request.ts +161 -0
  121. package/src/connectivity/transports/dav/xml.ts +123 -0
  122. package/src/connectivity/transports/factory.ts +181 -0
  123. package/src/connectivity/transports/fs/capabilities.ts +88 -0
  124. package/src/connectivity/transports/fs/commands.ts +258 -0
  125. package/src/connectivity/transports/fs/index.ts +121 -0
  126. package/src/connectivity/transports/fs/operations.ts +45 -0
  127. package/src/connectivity/transports/fs/paths.ts +120 -0
  128. package/src/connectivity/transports/fs/result.ts +12 -0
  129. package/src/connectivity/transports/http/index.ts +255 -0
  130. package/src/connectivity/transports/imap/attachment.ts +166 -0
  131. package/src/connectivity/transports/imap/capabilities.ts +158 -0
  132. package/src/connectivity/transports/imap/client.ts +398 -0
  133. package/src/connectivity/transports/imap/commands.ts +385 -0
  134. package/src/connectivity/transports/imap/index.ts +152 -0
  135. package/src/connectivity/transports/imap/operations.ts +64 -0
  136. package/src/connectivity/transports/imap/parse.ts +130 -0
  137. package/src/connectivity/transports/imap/parser.ts +272 -0
  138. package/src/connectivity/transports/imap/result.ts +15 -0
  139. package/src/connectivity/transports/imap/send.ts +92 -0
  140. package/src/connectivity/transports/imap/socket.ts +111 -0
  141. package/src/connectivity/transports/imap/utf7.ts +136 -0
  142. package/src/connectivity/transports/index.ts +20 -0
  143. package/src/connectivity/transports/local/index.ts +173 -0
  144. package/src/connectivity/transports/mcp/index.ts +215 -0
  145. package/src/deployments/README.md +63 -0
  146. package/src/deployments/adapters/audit-blob.ts +203 -0
  147. package/src/deployments/adapters/filesystem.ts +184 -0
  148. package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
  149. package/src/deployments/adapters/gcs.ts +191 -0
  150. package/src/deployments/adapters/otlp.ts +128 -0
  151. package/src/deployments/adapters/s3.ts +195 -0
  152. package/src/deployments/azure/README.md +21 -0
  153. package/src/deployments/bootstrap.ts +177 -0
  154. package/src/deployments/deploy.ts +290 -0
  155. package/src/deployments/driver.ts +157 -0
  156. package/src/deployments/drivers.ts +35 -0
  157. package/src/deployments/gcp/Dockerfile +70 -0
  158. package/src/deployments/gcp/cloudbuild.yaml +31 -0
  159. package/src/deployments/gcp/driver.ts +175 -0
  160. package/src/deployments/gcp/gcloud.ts +178 -0
  161. package/src/deployments/gcp/provision.ts +290 -0
  162. package/src/deployments/gcp/survey.ts +319 -0
  163. package/src/deployments/local/README.md +12 -0
  164. package/src/deployments/prepare.ts +257 -0
  165. package/src/deployments/steps.ts +137 -0
  166. package/src/deployments/target.ts +295 -0
  167. package/src/deployments/upload.ts +207 -0
  168. package/src/dispatch/context.ts +195 -0
  169. package/src/dispatch/dispatch.ts +350 -0
  170. package/src/dispatch/index.ts +32 -0
  171. package/src/dispatch/staging.ts +102 -0
  172. package/src/policy/index.ts +179 -0
  173. package/src/policy/limits.ts +77 -0
  174. package/src/profile/authorization.ts +81 -0
  175. package/src/profile/files.ts +71 -0
  176. package/src/profile/index.ts +76 -0
  177. package/src/profile/layout.ts +123 -0
  178. package/src/profile/load.ts +199 -0
  179. package/src/profile/primitives.ts +45 -0
  180. package/src/profile/schema.ts +347 -0
  181. package/src/profile/secret-detection.ts +162 -0
  182. package/src/profile/targets.ts +152 -0
  183. package/src/profile/workspace.ts +262 -0
  184. package/src/providers/custom/index.ts +21 -0
  185. package/src/providers/custom/load.ts +115 -0
  186. package/src/providers/custom/template.ts +156 -0
  187. package/src/providers/example/provider.ts +207 -0
  188. package/src/providers/google/calendar/index.ts +66 -0
  189. package/src/providers/google/calendar/redact.ts +40 -0
  190. package/src/providers/google/contacts/index.ts +50 -0
  191. package/src/providers/google/contacts/redact.ts +21 -0
  192. package/src/providers/google/docs/index.ts +45 -0
  193. package/src/providers/google/drive/hints.ts +28 -0
  194. package/src/providers/google/drive/index.ts +34 -0
  195. package/src/providers/google/drive/redact.ts +39 -0
  196. package/src/providers/google/drive-mcp/index.ts +21 -0
  197. package/src/providers/google/gmail/api.ts +42 -0
  198. package/src/providers/google/gmail/attachment.ts +142 -0
  199. package/src/providers/google/gmail/hints.ts +55 -0
  200. package/src/providers/google/gmail/index.ts +112 -0
  201. package/src/providers/google/gmail/redact.ts +56 -0
  202. package/src/providers/google/gmail/send.ts +365 -0
  203. package/src/providers/google/gmail-mcp/index.ts +35 -0
  204. package/src/providers/google/index.ts +10 -0
  205. package/src/providers/google/shared/oauth.ts +122 -0
  206. package/src/providers/google/shared/scopes.ts +99 -0
  207. package/src/providers/google/shared/setup.ts +80 -0
  208. package/src/providers/google/sheets/hints.ts +45 -0
  209. package/src/providers/google/sheets/index.ts +70 -0
  210. package/src/providers/google/sheets/redact.ts +45 -0
  211. package/src/providers/google/specs/calendar.v3.json +1829 -0
  212. package/src/providers/google/specs/docs.v1.json +381 -0
  213. package/src/providers/google/specs/drive.v3.json +2208 -0
  214. package/src/providers/google/specs/gmail.v1.json +2578 -0
  215. package/src/providers/google/specs/people.v1.json +506 -0
  216. package/src/providers/google/specs/sheets.v4.json +1269 -0
  217. package/src/providers/google/specs/tasks.v1.json +840 -0
  218. package/src/providers/google/specs/vendor.ts +661 -0
  219. package/src/providers/google/tasks/index.ts +53 -0
  220. package/src/providers/google/tasks/redact.ts +34 -0
  221. package/src/providers/harness.ts +95 -0
  222. package/src/providers/icloud/calendar/index.ts +27 -0
  223. package/src/providers/icloud/contacts/index.ts +17 -0
  224. package/src/providers/icloud/drive/index.ts +47 -0
  225. package/src/providers/icloud/index.ts +8 -0
  226. package/src/providers/icloud/mail/index.ts +37 -0
  227. package/src/providers/icloud/shared/setup.ts +66 -0
  228. package/src/providers/index.ts +93 -0
  229. package/src/providers/linear/index.ts +11 -0
  230. package/src/providers/linear/scopes.ts +7 -0
  231. package/src/providers/memory/provider.ts +429 -0
  232. package/src/providers/notion/index.ts +19 -0
  233. package/src/providers/owner.ts +49 -0
  234. package/src/providers/scopes.ts +26 -0
  235. package/src/providers/setup/plan.ts +141 -0
  236. package/src/providers/setup/provider.ts +323 -0
  237. package/src/providers/shared/frontmatter.ts +119 -0
  238. package/src/providers/skills/provider.ts +283 -0
  239. package/src/providers/skills/store.ts +252 -0
  240. package/src/providers/vault/provider.ts +194 -0
  241. package/src/registry/index.ts +36 -0
  242. package/src/registry/policy-bridge.ts +32 -0
  243. package/src/registry/reconcile.ts +313 -0
  244. package/src/registry/registry.ts +240 -0
  245. package/src/secrets/document.ts +293 -0
  246. package/src/secrets/index.ts +154 -0
  247. package/src/secrets/system.ts +173 -0
  248. package/src/secrets/vault.ts +336 -0
  249. package/src/server/attachments.ts +197 -0
  250. package/src/server/container.ts +96 -0
  251. package/src/server/edge.ts +53 -0
  252. package/src/server/endpoint.ts +352 -0
  253. package/src/server/generations.ts +362 -0
  254. package/src/server/harness.ts +400 -0
  255. package/src/server/index.ts +331 -0
  256. package/src/server/logging.ts +41 -0
  257. package/src/server/mcp/build.ts +68 -0
  258. package/src/server/mcp/icon.ts +145 -0
  259. package/src/server/mcp/index.ts +32 -0
  260. package/src/server/mcp/instructions.ts +245 -0
  261. package/src/server/mcp/naming.ts +39 -0
  262. package/src/server/mcp/prompts.ts +78 -0
  263. package/src/server/mcp/resources.ts +106 -0
  264. package/src/server/mcp/routing.ts +117 -0
  265. package/src/server/mcp/schema.ts +78 -0
  266. package/src/server/mcp/tools.ts +186 -0
  267. package/src/server/mcp/visibility.ts +132 -0
  268. package/src/server/oauth.ts +222 -0
  269. package/src/server/rebinding.ts +53 -0
  270. package/src/server/stdio.ts +192 -0
  271. package/src/stores/blobs/conformance.ts +223 -0
  272. package/src/stores/blobs/index.ts +125 -0
  273. package/src/stores/blobs/testing.ts +49 -0
  274. package/src/stores/state/index.ts +247 -0
  275. package/src/stores/state/keys.ts +68 -0
  276. package/src/stores/state/testing.ts +41 -0
@@ -0,0 +1,245 @@
1
+ import { RESERVED_PROVIDER_IDS } from '#connectivity';
2
+ import type { MergedCapability } from './visibility.ts';
3
+
4
+ /**
5
+ * What the endpoint says about itself, in the `initialize` response.
6
+ *
7
+ * A client that has just connected holds sixty tools with good individual
8
+ * descriptions and no account of what the thing *is* — that there is memory
9
+ * worth consulting before answering from nothing, that a skill is the owner's
10
+ * own procedure rather than a suggestion, that `profile` is how someone keeps
11
+ * work and personal apart and is therefore never a field to guess at. Every one
12
+ * of those is a habit, and a habit does not fit in a tool description.
13
+ *
14
+ * MCP has one field for this and we were not using it. It is the only channel
15
+ * that reaches a client with no skills directory and no config we may write —
16
+ * so it carries the part that must arrive everywhere, and the bundled skill
17
+ * carries the longer form for the two harnesses that can hold one.
18
+ *
19
+ * **Generated, not written.** The prose below is fixed; the facts under it are
20
+ * this principal's, computed from the same policy-filtered set the tools were
21
+ * registered from. It is rebuilt per request over HTTP, so it cannot describe a
22
+ * profile that has gone away or omit one that was added.
23
+ *
24
+ * Two constraints on editing it:
25
+ *
26
+ * - **Length is a recurring cost.** This lands in the system prompt of every
27
+ * session, so a paragraph added here is paid for on every request forever.
28
+ * `instructions.test.ts` holds a budget.
29
+ * - **No vendor may be named.** `src/architecture.test.ts` forbids it anywhere
30
+ * under `server/`, and rightly: the list below is whatever the owner has
31
+ * connected, and prose that named one would be wrong for everybody else.
32
+ */
33
+
34
+ /**
35
+ * The habits, in the order they are needed.
36
+ *
37
+ * Routing first because it gates every call; refusals last because that is when
38
+ * an agent is most tempted to improvise. Second person, and specific about what
39
+ * *not* to do — "ask which profile" is advice, "do not default to the first" is
40
+ * a rule.
41
+ *
42
+ * **Four of these are conditional**, and that is a correctness property rather
43
+ * than a saving. This used to be one fixed string that told every client to
44
+ * consult memory, invoke skills, and guard vault values — on an endpoint
45
+ * granting none of the three, which is the common case for a workspace that
46
+ * connected a mailbox and nothing else. Prose describing tools that are not
47
+ * there is worse than absent prose: it is a promise the tool list contradicts,
48
+ * and an agent resolves that by guessing. The saving is real too, and it is
49
+ * what pays for `SETUP` fitting inside the budget below.
50
+ *
51
+ * The opening no longer enumerates what is here for the same reason. The
52
+ * listing at the end says what is reachable, computed rather than asserted.
53
+ */
54
+ const OPENING = `This endpoint is one place to reach what its owner has chosen to expose. It
55
+ authenticates, applies permissions, and records what happened, so you do not
56
+ have to.`;
57
+
58
+ const ROUTING = `**Routing.** Every tool takes \`profile\` and \`connection\`. A profile is how
59
+ someone separates work from personal — when it is ambiguous which one is meant,
60
+ ask. Do not default to whichever is listed first.`;
61
+
62
+ const MEMORY = `**Memory is worth consulting.** Before concluding you do not know something
63
+ about this person or their work, search it. Writing to memory is a separate
64
+ grant, and what you write is served back to every later session — including to
65
+ a different agent — so write when asked to remember something, not by habit.`;
66
+
67
+ const SKILLS = `**Skills are the owner's procedures**, surfaced as prompts rather than tools.
68
+ That is deliberate: a procedure is selected by the person, not chosen by the
69
+ model, and you cannot read one's body. They belong to one profile, so a skill
70
+ you saw under one is not available under another. If a task has a skill for it,
71
+ say so and let them invoke it rather than improvising your own version.`;
72
+
73
+ const VAULT = `**Vault values are credentials.** Use one to do the thing that needs it. Do not
74
+ quote it back, summarise it, or write it anywhere.`;
75
+
76
+ /**
77
+ * The one that exists because its absence was observed, not predicted.
78
+ *
79
+ * Asked to connect a second mailbox, a client with no setup surface and no
80
+ * skill answered that it could not and then invented the procedure — edit the
81
+ * profile YAML, run "the auth command" — neither of which is a thing. It had
82
+ * no way to know `setup_overview` answers exactly that, so the instruction has
83
+ * to arrive here: this is the only channel that reaches a client which has
84
+ * merely been pointed at the URL.
85
+ */
86
+ const SETUP = `**What is set up is answerable.** Before saying something cannot be reached, or
87
+ that an account must be added, call \`setup_overview\` — then \`setup_provider\`
88
+ for the exact command. Running it is the owner's to do; inventing it is not.`;
89
+
90
+ const FILES = `**Files are named, not carried.** Where a tool takes attachments, give a path, an
91
+ HTTPS URL, or an attachment already on another message; the endpoint reads the
92
+ bytes. Never encode a file into a call — that is the thing this replaces.`;
93
+
94
+ const REFUSAL = `**A refused call is the permission system working**, not an obstacle to route
95
+ around. Report what was refused and let the owner decide whether to widen it.
96
+ Every call, including a refused one, is recorded.`;
97
+
98
+ /** Which paragraph each owner-layer provider brings, when it is reachable. */
99
+ const OWNER_HABITS: Record<string, string> = {
100
+ memory: MEMORY,
101
+ skills: SKILLS,
102
+ vault: VAULT,
103
+ setup: SETUP,
104
+ };
105
+
106
+ /**
107
+ * The whole string's ceiling, and the only budget there is.
108
+ *
109
+ * This lands in the system prompt of every session against this endpoint, so a
110
+ * paragraph added here is paid for on every request forever. Needing to raise
111
+ * it is the prompt to ask whether the paragraph belongs in the skill instead,
112
+ * where it is loaded only when relevant.
113
+ *
114
+ * Exported because the test asserted `2000` as a literal while the code
115
+ * reserved room against a second, differently-derived number — so the two could
116
+ * disagree, and did. There is no separate listing allowance any more: `spent`
117
+ * measures the prose that was actually assembled, and `spent + form.length` is
118
+ * exactly the final length, because `join` adds the same two characters the
119
+ * reduce already counted.
120
+ */
121
+ export const MAX_INSTRUCTIONS = 2000;
122
+
123
+ /** Which of the owner-layer providers this principal can actually reach. */
124
+ function ownerProviders(merged: ReadonlyMap<string, MergedCapability>): string[] {
125
+ const present = new Set<string>();
126
+
127
+ for (const id of merged.keys()) {
128
+ const provider = id.slice(0, id.indexOf('.'));
129
+ if (RESERVED_PROVIDER_IDS.includes(provider)) present.add(provider);
130
+ }
131
+
132
+ return RESERVED_PROVIDER_IDS.filter((id) => present.has(id));
133
+ }
134
+
135
+ /**
136
+ * The connections each profile contributes, deduplicated.
137
+ *
138
+ * Taken from `merged` rather than from each profile's config, so it lists what
139
+ * is *reachable* rather than what is configured. A connection the principal has
140
+ * no grant for is not registered on any tool, and announcing it here would
141
+ * describe a door that does not open.
142
+ */
143
+ function connectionsByProfile(
144
+ profiles: readonly string[],
145
+ merged: ReadonlyMap<string, MergedCapability>,
146
+ ): Map<string, string[]> {
147
+ const found = new Map<string, Set<string>>();
148
+
149
+ for (const entry of merged.values()) {
150
+ for (const [profile, connections] of entry.reachable) {
151
+ const known = found.get(profile) ?? new Set<string>();
152
+ for (const connection of connections) known.add(connection);
153
+ found.set(profile, known);
154
+ }
155
+ }
156
+
157
+ // Ordered by the served list rather than by discovery, so the listing is
158
+ // stable between requests and reads the same as everywhere else.
159
+ const listed = new Map<string, string[]>();
160
+ for (const profile of profiles) {
161
+ const known = found.get(profile);
162
+ if (known) listed.set(profile, [...known].sort());
163
+ }
164
+
165
+ return listed;
166
+ }
167
+
168
+ /**
169
+ * Profile *names*, not runtimes: this needs the order they are served in and
170
+ * nothing else, and a signature that asked for more would imply it reads more.
171
+ */
172
+ export function serverInstructions(
173
+ profiles: readonly string[],
174
+ merged: ReadonlyMap<string, MergedCapability>,
175
+ ): string {
176
+ const reachable = connectionsByProfile(profiles, merged);
177
+ const owner = ownerProviders(merged);
178
+
179
+ // Assembled per principal, because the owner layer is granted per principal.
180
+ // `ownerProviders` is already ordered by `RESERVED_PROVIDER_IDS`, so the
181
+ // paragraphs keep one order between requests rather than discovery order.
182
+ const sections = [
183
+ OPENING,
184
+ ROUTING,
185
+ ...owner.map((id) => OWNER_HABITS[id]).filter((habit): habit is string => habit !== undefined),
186
+ FILES,
187
+ REFUSAL,
188
+ ];
189
+
190
+ if (reachable.size === 0) {
191
+ // Not an error state worth hiding: a workspace with no connection yet, or a
192
+ // principal granted nothing, both land here, and saying so beats a heading
193
+ // with nothing under it.
194
+ sections.push(
195
+ 'Nothing is reachable through this endpoint yet — no connection is both configured and permitted.',
196
+ );
197
+ return sections.join('\n\n');
198
+ }
199
+
200
+ const lines = [...reachable].map(
201
+ ([profile, connections]) => ` ${profile}: ${connections.join(', ')}`,
202
+ );
203
+ const listing = `Reachable now, by profile:\n${lines.join('\n')}`;
204
+
205
+ // The prose above varies per principal; this listing grows with the workspace.
206
+ // Either can be the half that does not fit, so all three widths are measured
207
+ // against the one ceiling rather than against a reserve guessed in advance —
208
+ // which is how a workspace of one profile and one mailbox ended up being told
209
+ // "1 profiles" with a hundred characters of the budget unspent.
210
+ //
211
+ // Summarising rather than truncating, and safe to do: every tool carries the
212
+ // connections it accepts in its own `connection` enum, which is the
213
+ // authoritative list. This paragraph is orientation, so a count and the profile
214
+ // names lose nothing an agent cannot get exactly.
215
+ const total = [...reachable.values()].reduce((sum, list) => sum + list.length, 0);
216
+ const names = [...reachable.keys()];
217
+ const tail = "Each tool's `connection` argument lists the ones it accepts.";
218
+ const plural = names.length === 1 ? 'profile' : 'profiles';
219
+
220
+ // Widest first. The last is bounded — it names no profile — which is what
221
+ // makes the ceiling hold for a workspace of any size.
222
+ const forms = [
223
+ listing,
224
+ `Reachable now: ${total} connections across ${names.join(', ')}. ${tail}`,
225
+ `Reachable now: ${total} connections across ${names.length} ${plural}. ${tail}`,
226
+ ];
227
+
228
+ const spent = sections.reduce((count, section) => count + section.length + 2, 0);
229
+
230
+ // Every candidate is checked, including the last: it is shorter than naming
231
+ // twenty profiles but not shorter than naming one, so choosing it unmeasured
232
+ // both overran the budget in one direction and wasted it in the other. If
233
+ // nothing fits, the shortest is the most honest thing left to say.
234
+ sections.push(
235
+ forms.find((form) => spent + form.length < MAX_INSTRUCTIONS) ??
236
+ forms.reduce((shortest, form) => (form.length < shortest.length ? form : shortest)),
237
+ );
238
+
239
+ // No trailing "the owner's own material is here too, under: …" line any more.
240
+ // Each of those providers now brings its own paragraph when it is reachable,
241
+ // so the list repeated what the prose had just said — and it swept `setup`
242
+ // in with memory, skills and vault, which it is not: it holds none of the
243
+ // owner's material and only describes what the others are.
244
+ return sections.join('\n\n');
245
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Capability ids on the wire.
3
+ *
4
+ * MCP tool names are restricted to `[A-Za-z0-9_-]`, so the dotted capability id
5
+ * becomes `gmail_search` on the wire. The dotted form stays canonical
6
+ * everywhere it matters — config, policy rules, audit records — because that is
7
+ * what an operator reads and writes. Only the wire name is transliterated, and
8
+ * the mapping is total and reversible because provider ids and capability names
9
+ * both exclude `-`.
10
+ */
11
+
12
+ export const SERVER_NAME = 'lanes-link';
13
+
14
+ export function toolNameFor(capabilityId: string): string {
15
+ return capabilityId.replace(/\./g, '_');
16
+ }
17
+
18
+ /**
19
+ * Recover the capability id a wire name came from.
20
+ *
21
+ * Splitting on the first `_` was reversible while a capability name was one
22
+ * segment. It stopped being so when OpenAPI operationIds arrived dotted:
23
+ * `gmail_users_drafts_send` splits to `gmail.users_drafts_send`, which names
24
+ * nothing. That only shows up in the audit record for a refused tool — a log
25
+ * entry saying an agent tried something that does not exist, spelled wrongly,
26
+ * is worse than useless.
27
+ *
28
+ * So the known ids are consulted first, and the split is the fallback for a
29
+ * name matching no capability at all — where an approximate spelling is the
30
+ * best available and the attempt is what matters.
31
+ */
32
+ export function capabilityIdForToolName(toolName: string, known?: Iterable<string>): string {
33
+ for (const id of known ?? []) {
34
+ if (toolNameFor(id) === toolName) return id;
35
+ }
36
+
37
+ const index = toolName.indexOf('_');
38
+ return index === -1 ? toolName : `${toolName.slice(0, index)}.${toolName.slice(index + 1)}`;
39
+ }
@@ -0,0 +1,78 @@
1
+ import type { McpServer } from '@modelcontextprotocol/server';
2
+ import { z } from 'zod';
3
+ import { isPromptResult } from '#connectivity';
4
+ import { toolNameFor } from './naming.ts';
5
+ import { resolveScope } from './routing.ts';
6
+ import { describeWithConnections, type BuildServerOptions, type MergedCapability } from './visibility.ts';
7
+
8
+ /**
9
+ * Prompts — the owner's own procedures, surfaced as slash commands.
10
+ *
11
+ * Unlike a resource, a prompt *does* have arguments, so routing goes in them —
12
+ * ADR-001 rather than ADR-006's URI workaround. Both are optional and default
13
+ * when there is only one candidate, because a prompt's arguments are typically
14
+ * filled in by a person choosing a slash command, and making them type two
15
+ * routing strings to reach their only account would be a poor trade for
16
+ * consistency.
17
+ */
18
+ export function registerPrompt(
19
+ server: McpServer,
20
+ id: string,
21
+ entry: MergedCapability,
22
+ capability: Extract<NonNullable<MergedCapability['capability']>, { kind: 'prompt' }>,
23
+ options: BuildServerOptions,
24
+ ): void {
25
+ const profiles = [...entry.reachable.keys()];
26
+ const connections = [...new Set([...entry.reachable.values()].flat())];
27
+
28
+ const shape: Record<string, z.ZodType> = {};
29
+
30
+ for (const argument of capability.arguments ?? []) {
31
+ const declared = z.string().describe(argument.description);
32
+ shape[argument.name] = argument.required ? declared : declared.optional();
33
+ }
34
+
35
+ shape['profile'] = z
36
+ .string()
37
+ .optional()
38
+ .describe(`Which profile to act within: ${profiles.join(', ')}. Omit if there is one.`);
39
+ shape['connection'] = z
40
+ .string()
41
+ .optional()
42
+ .describe(`Which configured account: ${connections.join(', ')}. Omit if there is one.`);
43
+
44
+ server.registerPrompt(
45
+ toolNameFor(id),
46
+ {
47
+ ...(capability.title ? { title: capability.title } : {}),
48
+ description: describeWithConnections(capability.description, entry.reachable),
49
+ argsSchema: z.object(shape),
50
+ },
51
+ async (args: Record<string, unknown>) => {
52
+ const { profile, connection, ...rest } = args;
53
+ const scope = resolveScope(entry, profile, connection);
54
+ if ('error' in scope) throw new Error(scope.error);
55
+
56
+ const outcome = await options.profiles.get(scope.profile)!.dispatcher.invoke({
57
+ principal: options.principal,
58
+ capabilityId: id,
59
+ connectionKey: scope.connectionKey,
60
+ arguments: rest,
61
+ clientLabel: options.clientLabel,
62
+ });
63
+
64
+ // A prompt has no `isError` to carry a refusal in, so a denial is a
65
+ // protocol error. That is the honest mapping: there is no partial prompt
66
+ // to hand back, and the message still says why.
67
+ if (!outcome.ok) throw new Error(outcome.message);
68
+ if (!isPromptResult(outcome.result)) throw new Error(`${id} did not return a prompt`);
69
+
70
+ return {
71
+ messages: outcome.result.messages.map((message) => ({
72
+ role: message.role,
73
+ content: { type: 'text' as const, text: message.text },
74
+ })),
75
+ };
76
+ },
77
+ );
78
+ }
@@ -0,0 +1,106 @@
1
+ import { ResourceTemplate, type McpServer } from '@modelcontextprotocol/server';
2
+ import { isResourceListResult, isResourceResult } from '#connectivity';
3
+ import type { DispatchOutcome } from '#dispatch';
4
+ import { toolNameFor } from './naming.ts';
5
+ import { scopeResourceUri } from './routing.ts';
6
+ import type { BuildServerOptions, MergedCapability } from './visibility.ts';
7
+
8
+ /**
9
+ * Resources — read-oriented context, addressed rather than called.
10
+ *
11
+ * One registration per (profile, connection): a resource URI carries no
12
+ * argument to route on, so both have to live in the URI itself — ADR-006. A
13
+ * read still goes through the dispatcher like everything else, so a resource is
14
+ * policy-checked, rate-limited, and audited on exactly the terms a tool is.
15
+ */
16
+ export function registerResource(
17
+ server: McpServer,
18
+ id: string,
19
+ entry: MergedCapability,
20
+ capability: Extract<NonNullable<MergedCapability['capability']>, { kind: 'resource' }>,
21
+ options: BuildServerOptions,
22
+ ): void {
23
+ for (const [profile, reachable] of entry.reachable) {
24
+ const runtime = options.profiles.get(profile)!;
25
+
26
+ for (const connectionKey of reachable) {
27
+ const connectionId = connectionKey.slice(connectionKey.indexOf('.') + 1);
28
+ const scope = { profile, connectionId };
29
+ const scoped = scopeResourceUri(capability.uriTemplate, scope);
30
+
31
+ const dispatch = (args: Record<string, unknown>): Promise<DispatchOutcome> =>
32
+ runtime.dispatcher.invoke({
33
+ principal: options.principal,
34
+ capabilityId: id,
35
+ connectionKey,
36
+ arguments: args,
37
+ clientLabel: options.clientLabel,
38
+ });
39
+
40
+ const metadata = {
41
+ description: `${capability.description} (${profile}: ${connectionKey})`,
42
+ ...(capability.mimeType ? { mimeType: capability.mimeType } : {}),
43
+ };
44
+
45
+ // `uri` is the whole argument — the provider recovers its own template
46
+ // variables from it and never sees the routing segments core prepended.
47
+ const read = async (uri: URL) => {
48
+ const outcome = await dispatch({ uri: uri.href });
49
+ if (!outcome.ok) throw new Error(outcome.message);
50
+ if (!isResourceResult(outcome.result)) {
51
+ throw new Error(`${id} did not return resource contents`);
52
+ }
53
+
54
+ return {
55
+ contents: outcome.result.contents.map((part) => ({
56
+ // The requested URI, not whatever the provider echoed: MCP requires
57
+ // them to match, and a provider returning its own unscoped form
58
+ // would produce contents a client cannot re-read.
59
+ uri: uri.href,
60
+ ...(part.mimeType ?? capability.mimeType
61
+ ? { mimeType: part.mimeType ?? capability.mimeType! }
62
+ : {}),
63
+ text: part.text,
64
+ })),
65
+ };
66
+ };
67
+
68
+ const name = `${toolNameFor(id)}_${profile}_${connectionId}`;
69
+
70
+ // A template with nothing to expand is a static resource, and the SDK
71
+ // wants it registered as one — that is what puts it in `resources/list`
72
+ // without a list callback.
73
+ if (!scoped.includes('{')) {
74
+ server.registerResource(name, scoped, metadata, read);
75
+ continue;
76
+ }
77
+
78
+ server.registerResource(
79
+ name,
80
+ new ResourceTemplate(scoped, {
81
+ // `undefined` is a deliberate value here, not an omission: the SDK
82
+ // requires the key so that forgetting to enumerate is a decision
83
+ // rather than an oversight. A provider omits `list` when its resource
84
+ // space is unbounded.
85
+ list: capability.list
86
+ ? async () => {
87
+ const outcome = await dispatch({});
88
+ if (!outcome.ok) throw new Error(outcome.message);
89
+ if (!isResourceListResult(outcome.result)) return { resources: [] };
90
+
91
+ return {
92
+ resources: outcome.result.resources.map((resource) => ({
93
+ name: resource.name,
94
+ uri: scopeResourceUri(resource.uri, scope),
95
+ ...(capability.mimeType ? { mimeType: capability.mimeType } : {}),
96
+ })),
97
+ };
98
+ }
99
+ : undefined,
100
+ }),
101
+ metadata,
102
+ read,
103
+ );
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,117 @@
1
+ import { isResource } from '#connectivity';
2
+ import type { MergedCapability, ProfileRuntime } from './visibility.ts';
3
+
4
+ /**
5
+ * Getting the profile and the connection into an address.
6
+ *
7
+ * A tool takes them as injected arguments (ADR-001). A resource has no argument
8
+ * to route on, so they go into the URI; a prompt has arguments but is typically
9
+ * filled in by a person choosing a slash command, so they are optional there and
10
+ * default when there is only one candidate.
11
+ */
12
+
13
+ /**
14
+ * Put the profile and the connection into a resource URI.
15
+ *
16
+ * They have to go somewhere that works for *any* template, not just one that
17
+ * happens to spell `{key}`, so they are inserted as the first two path segments
18
+ * directly after the authority: `example://note/{key}` becomes
19
+ * `example://note/personal/a/{key}`, and `memory://entry/{id}` becomes
20
+ * `memory://entry/personal/owner/{id}`.
21
+ *
22
+ * The previous form substituted the literal token `{key}`, which meant any
23
+ * provider naming its variable anything else — every provider except `example` —
24
+ * registered a URI with no routing in it at all, and two connections would have
25
+ * collided on one address.
26
+ */
27
+ export function scopeResourceUri(
28
+ uriOrTemplate: string,
29
+ scope: { profile: string; connectionId: string },
30
+ ): string {
31
+ const separator = uriOrTemplate.indexOf('://');
32
+ if (separator === -1) return uriOrTemplate;
33
+
34
+ const scheme = uriOrTemplate.slice(0, separator + 3);
35
+ const rest = uriOrTemplate.slice(separator + 3);
36
+ const slash = rest.indexOf('/');
37
+ const authority = slash === -1 ? rest : rest.slice(0, slash);
38
+ const path = slash === -1 ? '' : rest.slice(slash);
39
+
40
+ return `${scheme}${authority}/${scope.profile}/${scope.connectionId}${path}`;
41
+ }
42
+
43
+ /** `scheme://authority`, or empty for anything not shaped like that. */
44
+ function originOf(uri: string): string {
45
+ const separator = uri.indexOf('://');
46
+ if (separator === -1) return '';
47
+
48
+ const rest = uri.slice(separator + 3);
49
+ const slash = rest.indexOf('/');
50
+ return uri.slice(0, separator + 3) + (slash === -1 ? rest : rest.slice(0, slash));
51
+ }
52
+
53
+ /**
54
+ * Route the `resource_link`s a tool hands back.
55
+ *
56
+ * A provider names its own resources — `memory://entry/deploy_window` — because
57
+ * it does not know, and must not learn, which profile or connection it is
58
+ * serving. Core does. Without this, `memory.search` returns addresses that look
59
+ * like resources and cannot be read, which is worse than returning none.
60
+ *
61
+ * Scoped **only** for a link whose scheme and authority match one of this
62
+ * provider's own resource templates. A link to somewhere else — an `https://`
63
+ * document a vendor returned — is left exactly as the vendor wrote it; inserting
64
+ * routing segments into someone else's URL would corrupt it.
65
+ */
66
+ export function resourceLinkRouter(
67
+ runtime: ProfileRuntime,
68
+ capabilityId: string,
69
+ profile: string,
70
+ connectionKey: string,
71
+ ): (uri: string) => string {
72
+ const providerId = capabilityId.slice(0, capabilityId.indexOf('.'));
73
+ const definition = runtime.registry.get(providerId)?.definition;
74
+ if (!definition) return (uri) => uri;
75
+
76
+ const origins = new Set(
77
+ definition.capabilities
78
+ .filter(isResource)
79
+ .map((capability) => originOf(capability.uriTemplate)),
80
+ );
81
+ if (origins.size === 0) return (uri) => uri;
82
+
83
+ const connectionId = connectionKey.slice(connectionKey.indexOf('.') + 1);
84
+
85
+ return (uri) =>
86
+ origins.has(originOf(uri)) ? scopeResourceUri(uri, { profile, connectionId }) : uri;
87
+ }
88
+
89
+ /**
90
+ * Which profile and connection a prompt call meant.
91
+ *
92
+ * Same refusal a tool gets when a profile and a connection belong to different
93
+ * profiles, for the same reason: the enums are a union, and routing a `work`
94
+ * account through `personal` would cross exactly the boundary profiles exist to
95
+ * hold.
96
+ */
97
+ export function resolveScope(
98
+ entry: MergedCapability,
99
+ profile: unknown,
100
+ connection: unknown,
101
+ ): { profile: string; connectionKey: string } | { error: string } {
102
+ const profiles = [...entry.reachable.keys()];
103
+ const name = typeof profile === 'string' ? profile : (profiles.length === 1 ? profiles[0]! : '');
104
+
105
+ const reachable = entry.reachable.get(name);
106
+ if (!reachable) {
107
+ return { error: `Name a profile: ${profiles.join(', ')}` };
108
+ }
109
+
110
+ const key =
111
+ typeof connection === 'string' ? connection : (reachable.length === 1 ? reachable[0]! : '');
112
+ if (!reachable.includes(key)) {
113
+ return { error: `Name a connection within profile "${name}": ${reachable.join(', ')}` };
114
+ }
115
+
116
+ return { profile: name, connectionKey: key };
117
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Making a discovered schema safe to publish.
3
+ *
4
+ * Two problems, both arriving from vendors rather than from us, and both fixed
5
+ * here rather than per-provider so an upstream MCP server and a hand-written
6
+ * workspace manifest get the same treatment as Google's specs.
7
+ */
8
+
9
+ /**
10
+ * Property names a tool schema may use.
11
+ *
12
+ * The Anthropic API enforces `^[a-zA-Z0-9_.-]{1,64}$` on every property key,
13
+ * and rejects the **entire** `tools` array when one fails — so a single bad key
14
+ * anywhere takes down every provider on the endpoint, not just its own. Google's
15
+ * specs ship `$.xgafv`, which is exactly that.
16
+ */
17
+ const LEGAL_PROPERTY = /^[a-zA-Z0-9_.-]{1,64}$/;
18
+
19
+ /**
20
+ * JSON Schema's own `format` values.
21
+ *
22
+ * Anything else is an OpenAPI or vendor annotation — `int64`, `uint64`,
23
+ * `float`, `byte`, Google's `google` — and a validator that does not recognise
24
+ * it logs a warning for every occurrence, every time a schema is compiled.
25
+ * Google's specs carry six such formats, which is where "unknown format uint64
26
+ * ignored" comes from.
27
+ */
28
+ const STANDARD_FORMATS = new Set([
29
+ 'date-time', 'date', 'time', 'duration',
30
+ 'email', 'idn-email', 'hostname', 'idn-hostname',
31
+ 'ipv4', 'ipv6', 'uri', 'uri-reference', 'uri-template',
32
+ 'iri', 'iri-reference', 'uuid', 'regex',
33
+ 'json-pointer', 'relative-json-pointer',
34
+ ]);
35
+
36
+ /**
37
+ * **Illegal property names are dropped.** Dropping rather than renaming: the
38
+ * name is what the connector maps back to a request parameter, so a renamed key
39
+ * would arrive upstream as something the vendor does not recognise. A *required*
40
+ * property is left in place, to fail loudly rather than register a tool that can
41
+ * never be called correctly.
42
+ *
43
+ * **Non-standard `format` values are dropped.** They are annotations a JSON
44
+ * Schema validator has no rule for; it ignores them and says so, once per
45
+ * occurrence per compile. Removing them loses nothing — `type` still carries the
46
+ * constraint that matters — and removes a stream of warnings that buries
47
+ * anything worth reading.
48
+ */
49
+ export function sanitizeSchema(schema: Record<string, unknown>): Record<string, unknown> {
50
+ const walk = (node: unknown): unknown => {
51
+ if (Array.isArray(node)) return node.map(walk);
52
+ if (node === null || typeof node !== 'object') return node;
53
+
54
+ const record = node as Record<string, unknown>;
55
+ const out: Record<string, unknown> = {};
56
+
57
+ const required = new Set((record['required'] as string[] | undefined) ?? []);
58
+
59
+ for (const [key, value] of Object.entries(record)) {
60
+ if (key === 'format' && typeof value === 'string' && !STANDARD_FORMATS.has(value)) continue;
61
+
62
+ if (key === 'properties' && value !== null && typeof value === 'object') {
63
+ out[key] = Object.fromEntries(
64
+ Object.entries(value as Record<string, unknown>)
65
+ .filter(([name]) => LEGAL_PROPERTY.test(name) || required.has(name))
66
+ .map(([name, child]) => [name, walk(child)]),
67
+ );
68
+ continue;
69
+ }
70
+
71
+ out[key] = walk(value);
72
+ }
73
+
74
+ return out;
75
+ };
76
+
77
+ return walk(schema) as Record<string, unknown>;
78
+ }