@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,331 @@
1
+ import { challenge, type Authenticator } from '#auth';
2
+ import type { Logger } from '#connectivity';
3
+ import { capabilityIdForToolName } from '#server/mcp';
4
+ import { ATTACHMENTS_PATH, stageAttachment } from './attachments.ts';
5
+ import { allowedHostnamesFor, rebindingRefusal } from './rebinding.ts';
6
+ import type { Generation, Generations } from './generations.ts';
7
+ import { callerKey, failedAuthLimiter, FAILED_AUTH_PER_MINUTE, tooManyAttempts } from './edge.ts';
8
+ import {
9
+ handleAuthorization,
10
+ isAuthorizationPath,
11
+ resourceMetadataUrl,
12
+ type AuthorizationSurface,
13
+ } from './oauth.ts';
14
+
15
+ /**
16
+ * The HTTP surface.
17
+ *
18
+ * Stateless streamable HTTP on a single endpoint. Statelessness is not an
19
+ * optimisation: the M2 target replaces instances between requests, so any
20
+ * in-memory session state would produce intermittent 404s. `createMcpHandler`
21
+ * builds a fresh server instance per request from the generation's factory,
22
+ * which is also what lets the tool list be a pure function of the caller's
23
+ * policy.
24
+ *
25
+ * Authentication happens here, in front of that factory, so the factory only
26
+ * ever runs for a caller whose identity is already established.
27
+ *
28
+ * What this file is *not* responsible for is which runtimes are current. A
29
+ * request pins one generation on the way in and uses it throughout, so a reload
30
+ * landing mid-request cannot change what that request is evaluated against.
31
+ * See `./generations.ts`.
32
+ */
33
+
34
+ export interface ServerOptions {
35
+ /** The current profile runtimes, and the reload that replaces them. */
36
+ readonly generations: Generations;
37
+ /** Which profile's port, host, and token govern the endpoint itself. */
38
+ readonly primary: string;
39
+ readonly authenticator: Authenticator;
40
+ readonly log: Logger;
41
+ readonly version?: string;
42
+ /**
43
+ * How a remote client obtains a token, when the profile declares one.
44
+ *
45
+ * Absent means bearer-token-only: no metadata is published, the `401` carries
46
+ * no pointer, and the endpoint behaves exactly as it did before.
47
+ */
48
+ readonly authorization?: AuthorizationSurface | undefined;
49
+ /** Hostnames this endpoint answers to. See `./rebinding.ts`. */
50
+ readonly allowedHostnames?: readonly string[] | undefined;
51
+ }
52
+
53
+ export const MCP_PATH = '/mcp';
54
+ export const RELOAD_PATH = '/reload';
55
+
56
+ /**
57
+ * Loopback addresses. What binding to one changes is the browser threat model,
58
+ * not the auth model — see `allowedHostnames`.
59
+ */
60
+ const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
61
+
62
+ export function isLoopback(host: string): boolean {
63
+ return LOOPBACK.has(host);
64
+ }
65
+
66
+ /**
67
+ * How often an unrecognised tool name may provoke a reload.
68
+ *
69
+ * The safety net below reloads when a call names a tool this endpoint does not
70
+ * advertise, which is what a just-connected provider looks like to an instance
71
+ * that missed the notify. An agent retrying a genuinely absent tool must not
72
+ * turn that into a reload per call, so it is bounded.
73
+ */
74
+ const RELOAD_PROBE_MS = 10_000;
75
+
76
+ export interface RequestHandler {
77
+ fetch(request: Request): Promise<Response>;
78
+ close(): Promise<void>;
79
+ }
80
+
81
+ export function createRequestHandler(options: ServerOptions): RequestHandler {
82
+ let probedAt = 0;
83
+ const failedAuth = failedAuthLimiter();
84
+
85
+ /**
86
+ * Re-read the config because a call named a tool we do not serve.
87
+ *
88
+ * The notify (ADR-029) reaches one instance. A second instance that was warm
89
+ * when the operator connected an account keeps refusing it, which would make
90
+ * "connecting does not need a redeploy" true only sometimes — the worst of
91
+ * the three possible states, because nobody can reproduce it.
92
+ *
93
+ * So the error path closes it. A tool that appeared a moment ago is
94
+ * indistinguishable from a tool that never existed, and both arrive here; one
95
+ * reload tells them apart. Nothing on the success path pays for this.
96
+ */
97
+ const probeForNewConfig = async (): Promise<boolean> => {
98
+ const now = Date.now();
99
+ if (now - probedAt < RELOAD_PROBE_MS) return false;
100
+ probedAt = now;
101
+
102
+ const result = await options.generations.reload();
103
+ return result.reloaded;
104
+ };
105
+
106
+ return {
107
+ async fetch(request) {
108
+ // Ahead of everything, including the pre-auth discovery routes: those are
109
+ // exactly what a rebound origin reaches.
110
+ if (options.allowedHostnames) {
111
+ const refusal = rebindingRefusal(request, options.allowedHostnames);
112
+ if (refusal) return refusal;
113
+ }
114
+
115
+ const url = new URL(request.url);
116
+
117
+ // Before authentication, deliberately: a client's first request is the
118
+ // one that discovers how to authenticate, so requiring a token to read
119
+ // the document that says where tokens come from would close the loop it
120
+ // exists to open.
121
+ if (options.authorization && isAuthorizationPath(url.pathname)) {
122
+ return await handleAuthorization(request, options.authorization);
123
+ }
124
+
125
+ if (url.pathname === '/health') {
126
+ // `status` is unauthenticated because the platform's own probe reads it
127
+ // and a deploy waits on it. The profile *names* are not: on a public URL
128
+ // that is a list of what this endpoint holds, handed to anyone who asks,
129
+ // and `outputs` and `mcp add` — which are the reason it was ever
130
+ // published — both hold the token already.
131
+ const named = await options.authenticator.authenticate(
132
+ request.headers.get('authorization'),
133
+ );
134
+
135
+ return Response.json({
136
+ status: 'ok',
137
+ ...(named.ok
138
+ ? { profile: options.primary, profiles: options.generations.current.names() }
139
+ : {}),
140
+ });
141
+ }
142
+
143
+ if (
144
+ url.pathname !== MCP_PATH &&
145
+ url.pathname !== ATTACHMENTS_PATH &&
146
+ url.pathname !== RELOAD_PATH
147
+ ) {
148
+ return new Response('Not found', { status: 404 });
149
+ }
150
+
151
+ const outcome = await options.authenticator.authenticate(
152
+ request.headers.get('authorization'),
153
+ );
154
+
155
+ if (!outcome.ok) {
156
+ options.log.warn('rejected request', { reason: outcome.reason });
157
+
158
+ // After the attempt rather than before it: keyed on the caller alone,
159
+ // anyone able to reach the endpoint could spend the owner's budget and
160
+ // lock them out, which trades a cost problem for a worse availability
161
+ // one. Only a failure consumes a token, so a valid credential is never
162
+ // refused by this.
163
+ const budget = failedAuth.take(callerKey(request), FAILED_AUTH_PER_MINUTE);
164
+ if (!budget.allowed) return tooManyAttempts(budget.retryAfterMs);
165
+
166
+ // The pointer is the whole handshake for a remote client: it reads the
167
+ // named document, finds the authorization server, and starts a flow.
168
+ // Without it the client has to guess the document's location, and a
169
+ // client that guesses wrong reports the endpoint as unreachable.
170
+ const metadata = options.authorization ? resourceMetadataUrl(request) : null;
171
+
172
+ return new Response(
173
+ JSON.stringify({
174
+ error: 'unauthorized',
175
+ reason: outcome.reason,
176
+ hint:
177
+ outcome.reason === 'not_configured'
178
+ ? 'This profile has no token yet. Run: lanes link token rotate'
179
+ : 'Present the profile token as: Authorization: Bearer <token>',
180
+ }),
181
+ {
182
+ status: 401,
183
+ headers: {
184
+ 'content-type': 'application/json',
185
+ 'www-authenticate': challenge(metadata),
186
+ },
187
+ },
188
+ );
189
+ }
190
+
191
+ // Behind the same bearer check as everything else, and deliberately not a
192
+ // way to *change* configuration: it re-reads what the CLI already wrote to
193
+ // the store this endpoint boots from, so the one-way flow ADR-004 requires
194
+ // — local CLI to instance — is exactly what it follows. ADR-029.
195
+ if (url.pathname === RELOAD_PATH) {
196
+ const result = await options.generations.reload();
197
+ return Response.json(result);
198
+ }
199
+
200
+ // A request works against one generation for its whole lifetime. Pinning
201
+ // it here rather than reading `current` at each use is what makes a reload
202
+ // landing mid-request invisible to this one: the config it is evaluated
203
+ // against cannot change between two of its own awaits.
204
+ let generation = options.generations.acquire();
205
+
206
+ try {
207
+ // Bytes in, handle out — the one thing a tool argument cannot carry,
208
+ // since a file in a tool call is base64 in the model's output. It stages
209
+ // into one named connection rather than a shared area, so a staged file
210
+ // stays as isolated as the account it was staged for.
211
+ if (url.pathname === ATTACHMENTS_PATH) {
212
+ return await stageAttachment({
213
+ profiles: generation.profiles,
214
+ primary: options.primary,
215
+ principal: outcome.principal,
216
+ request,
217
+ clientLabel: request.headers.get('x-mcp-client') ?? undefined,
218
+ });
219
+ }
220
+
221
+ // After authentication, so an unauthenticated caller cannot make the
222
+ // endpoint poll its store. Before `visible()`, because a skill added
223
+ // since the last poll must not be audited as a refusal on its first call.
224
+ await generation.refreshSkills();
225
+
226
+ const clientLabel = request.headers.get('x-mcp-client') ?? undefined;
227
+
228
+ // Policy-filtered discovery means an unpermitted tool is never
229
+ // advertised, so a call naming one is rejected by the protocol layer
230
+ // before dispatch — and would otherwise leave no trace. The 2026-07-28
231
+ // envelope requires the method and target in headers and rejects any
232
+ // request whose headers and body disagree, so reading them here is exact
233
+ // without parsing (and consuming) the body.
234
+ //
235
+ // **Only for an envelope client.** A 2025-era request carries neither
236
+ // header, and `createMcpHandler` is built without a `legacy` option —
237
+ // whose default is `'stateless'`, so those requests are served rather
238
+ // than refused. This check short-circuits and the refusal goes
239
+ // unrecorded. That is the second documented exception to
240
+ // `audit.every-invocation` in `docs/detailed/security.md`, asserted in
241
+ // `index.test.ts`. Closing it means cloning and parsing the body when
242
+ // the header is absent, which is what `stdio.ts` does for want of
243
+ // headers.
244
+ //
245
+ // `prompts/get` is included because a prompt is named exactly as a tool
246
+ // is — `skills_review-diff` — so the same lookup is exact.
247
+ // `resources/read` is **not**, and cannot be with this shape: 2026-07-28
248
+ // mirrors `params.uri` rather than a name into the header, and a URI does
249
+ // not match a wire name, so including it would record a refusal for every
250
+ // successful read. Recovering a capability id from a concrete URI means
251
+ // matching it against each registered template, which is a real design
252
+ // decision — not least about what to record when it matches nothing —
253
+ // and M4 did not take it. `resources.test.ts` asserts the gap.
254
+ const method = request.headers.get('mcp-method');
255
+ if (method === 'tools/call' || method === 'prompts/get') {
256
+ const toolName = request.headers.get('mcp-name');
257
+
258
+ if (toolName && !generation.visible().has(toolName)) {
259
+ // Before recording it as a refusal: this instance may simply be
260
+ // holding config older than the account the caller is naming.
261
+ if (await probeForNewConfig()) {
262
+ await options.generations.release(generation);
263
+ generation = options.generations.acquire();
264
+ }
265
+ }
266
+
267
+ if (toolName && !generation.visible().has(toolName)) {
268
+ // Recorded against the primary profile: the body has not been read,
269
+ // so which profile the call named is not yet known, and an attempt on
270
+ // a tool no profile advertises belongs to none of them in particular.
271
+ await generation.profiles.get(options.primary)!.dispatcher.recordRefusal({
272
+ principal: outcome.principal,
273
+ capabilityId: capabilityIdForToolName(toolName, generation.allCapabilityIds()),
274
+ clientLabel,
275
+ });
276
+ }
277
+ }
278
+
279
+ return await generation.handlerFor(outcome.principal, clientLabel).fetch(request);
280
+ } finally {
281
+ await options.generations.release(generation);
282
+ }
283
+ },
284
+
285
+ async close() {
286
+ await options.generations.close();
287
+ },
288
+ };
289
+ }
290
+
291
+ export interface ServeOptions extends ServerOptions {
292
+ readonly port?: number;
293
+ readonly host?: string;
294
+ }
295
+
296
+ export interface RunningServer {
297
+ readonly url: string;
298
+ stop(): Promise<void>;
299
+ }
300
+
301
+ export function serve(options: ServeOptions): RunningServer {
302
+ const current: Generation = options.generations.current;
303
+ const primary = current.profiles.get(options.primary);
304
+ if (!primary) throw new Error(`Profile "${options.primary}" is not among those being served.`);
305
+
306
+ // Read once, from the generation that is current at bind time. A reload
307
+ // cannot move a bound socket, so `instance.port` and `instance.host` are
308
+ // deliberately not part of what reloading re-reads (ADR-029).
309
+ const host = options.host ?? primary.config.instance.host;
310
+ const port = options.port ?? primary.config.instance.port;
311
+
312
+ const allowedHostnames = options.allowedHostnames ?? allowedHostnamesFor(host, isLoopback(host));
313
+ const handler = createRequestHandler({
314
+ ...options,
315
+ ...(allowedHostnames ? { allowedHostnames } : {}),
316
+ });
317
+
318
+ const server = Bun.serve({
319
+ hostname: host,
320
+ port,
321
+ fetch: (request: Request) => handler.fetch(request),
322
+ });
323
+
324
+ return {
325
+ url: `http://${host}:${port}${MCP_PATH}`,
326
+ async stop() {
327
+ await handler.close();
328
+ await server.stop(true);
329
+ },
330
+ };
331
+ }
@@ -0,0 +1,41 @@
1
+ import type { Logger } from '#connectivity';
2
+
3
+ export type { Logger };
4
+
5
+ /**
6
+ * Where an endpoint's operational events go.
7
+ *
8
+ * Distinct from the audit log, which records what a caller *did* and is a
9
+ * durable, hash-chained artefact. This is the other half: what happened to
10
+ * requests that never reached dispatch, of which a rejected credential is the
11
+ * one that matters. Audit cannot cover it — a refusal record needs a principal,
12
+ * and failing authentication is precisely not having one.
13
+ *
14
+ * Every caller used to pass an object whose four methods were empty, so the
15
+ * warning at the authentication edge was written and discarded on a public URL.
16
+ */
17
+
18
+ /** Discards everything. For tests, whose output should stay readable. */
19
+ export function silentLogger(): Logger {
20
+ return { debug() {}, info() {}, warn() {}, error() {} };
21
+ }
22
+
23
+ /**
24
+ * Timestamped lines to whichever stream is handed in.
25
+ *
26
+ * The CLI passes stderr, because stdout is what `--raw` and `--json` callers
27
+ * parse. The container passes stdout, which is where Cloud Run collects logs.
28
+ */
29
+ export function streamLogger(write: (line: string) => void, now = () => new Date()): Logger {
30
+ const at = (level: string, message: string, detail?: Record<string, unknown>): void => {
31
+ const suffix = detail && Object.keys(detail).length > 0 ? ` ${JSON.stringify(detail)}` : '';
32
+ write(`${now().toISOString()} ${level} ${message}${suffix}`);
33
+ };
34
+
35
+ return {
36
+ debug: (message, detail) => at('debug', message, detail),
37
+ info: (message, detail) => at('info', message, detail),
38
+ warn: (message, detail) => at('warn', message, detail),
39
+ error: (message, detail) => at('error', message, detail),
40
+ };
41
+ }
@@ -0,0 +1,68 @@
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
+ import { isPrompt, isResource, isTool } from '#connectivity';
3
+ import { SERVER_ICONS } from './icon.ts';
4
+ import { serverInstructions } from './instructions.ts';
5
+ import { SERVER_NAME } from './naming.ts';
6
+ import { registerPrompt } from './prompts.ts';
7
+ import { registerResource } from './resources.ts';
8
+ import { registerDiscoveredTool, registerLocalTool } from './tools.ts';
9
+ import { mergeCapabilities, type BuildServerOptions } from './visibility.ts';
10
+
11
+ /**
12
+ * Building the MCP surface for one principal.
13
+ *
14
+ * Every registration below is downstream of `mergeCapabilities`, which has
15
+ * already applied policy — so this loop never decides what a caller may see, it
16
+ * only decides how each surviving capability is spelled in the protocol.
17
+ */
18
+ export function buildMcpServer(options: BuildServerOptions): McpServer {
19
+ const names = [...options.profiles.keys()];
20
+
21
+ // One evaluation, two consumers. `mergeCapabilities` runs policy over every
22
+ // capability on every connection, and the instructions describe exactly the
23
+ // set that gets registered below — computing it twice would be the same
24
+ // answer at twice the cost, and two answers the day they drift.
25
+ const merged = mergeCapabilities(options);
26
+
27
+ const server = new McpServer(
28
+ {
29
+ name: SERVER_NAME,
30
+ version: options.version ?? '0.0.0',
31
+ title: `Lanes Link — ${names.join(', ')}`,
32
+ // First argument, and this is the half of the pairing below that is easy
33
+ // to get wrong in the other direction: `icons`, `description` and
34
+ // `websiteUrl` are `Implementation` fields that SEP-973 added, so they
35
+ // belong here and would be dropped from `ServerOptions`.
36
+ //
37
+ // `description` is `package.json`'s, and the test holds the two equal
38
+ // rather than trusting a copy to stay one. It is the whole endpoint being
39
+ // described, not this connection, so it does not name the profiles the
40
+ // way `title` does.
41
+ description: 'A self-hosted MCP gateway for your accounts, memory, skills, and secrets',
42
+ websiteUrl: 'https://github.com/lanes-sh/link',
43
+ icons: SERVER_ICONS,
44
+ },
45
+ // Second argument, not the first: `instructions` is a `ServerOptions` field,
46
+ // and `Implementation` would take it as an unknown extra and drop it from
47
+ // `initialize` without complaining.
48
+ { instructions: serverInstructions(names, merged) },
49
+ );
50
+
51
+ for (const [id, entry] of merged) {
52
+ // Discovered first: an upstream MCP server or an OpenAPI document supplies
53
+ // the schema, and there is no local capability object to inspect.
54
+ if (entry.discovered) {
55
+ registerDiscoveredTool(server, id, entry, options);
56
+ continue;
57
+ }
58
+
59
+ const capability = entry.capability;
60
+ if (!capability) continue;
61
+
62
+ if (isTool(capability)) registerLocalTool(server, id, entry, capability, options);
63
+ else if (isResource(capability)) registerResource(server, id, entry, capability, options);
64
+ else if (isPrompt(capability)) registerPrompt(server, id, entry, capability, options);
65
+ }
66
+
67
+ return server;
68
+ }
@@ -0,0 +1,145 @@
1
+ import type { Icon } from '@modelcontextprotocol/server';
2
+
3
+ /**
4
+ * The mark the endpoint reports to a client that renders one.
5
+ *
6
+ * SEP-973 (protocol revision 2025-11-25) added `icons` to `Implementation`, so
7
+ * `initialize.result.serverInfo` can carry the server's own branding rather
8
+ * than leaving every client to generate a letter avatar from the name.
9
+ *
10
+ * **Nothing renders this in Claude today.** claude.ai shows custom connectors
11
+ * with a generated avatar and ignores `serverInfo.icons` entirely — as does
12
+ * Claude Code. That is a client gap, tracked in anthropics/claude-ai-mcp#152,
13
+ * where the reporter ruled out every server-side route in turn: `icons` with an
14
+ * https src, `icons` with a data URI, `/favicon.ico` and `/favicon.png` at the
15
+ * origin, and an HTML `<link rel="icon">`. None are fetched.
16
+ *
17
+ * It is here anyway because the cost is a few kilobytes on `initialize` and the
18
+ * alternative is a release the day the gap closes. Other clients already
19
+ * honour the field.
20
+ */
21
+
22
+ /**
23
+ * The tile: the published mark's own geometry, used verbatim and not inset.
24
+ *
25
+ * These are the numbers from the published file — a 737.28 square at a
26
+ * sub-pixel offset inside a 738 × 739 box, with `rx="150"` corners. The tile is
27
+ * both the ground and the mask, which is what makes the stripes stop exactly
28
+ * where the rounded square does.
29
+ *
30
+ * **The mark is not inset, and that is a deliberate reversal.** An earlier
31
+ * version held it a tenth of the box clear of the edge, reasoning that a
32
+ * circular avatar crop would otherwise take the rounded-square silhouette. It
33
+ * does take it — but what the margin actually produced was a dark ring inside
34
+ * the circle with the stripes shrunk away from it, which reads as a small mark
35
+ * badly placed rather than as a mark whose corners were cropped. Filling the
36
+ * box is the better trade in both shapes: cropped to a circle the stripes run
37
+ * edge to edge, and left square the silhouette is there anyway.
38
+ */
39
+ const TILE = { x: 0.360107, y: 0.859375, size: 737.28, radius: 150 } as const;
40
+
41
+ /** The box the published file declares, which the tile sits inside. */
42
+ const WIDTH = 738;
43
+ const HEIGHT = 739;
44
+
45
+ const GROUND = '#121214';
46
+ const INK = '#ffffff';
47
+
48
+ /**
49
+ * The icon: the published Lanes mark, filling its box, on its own dark ground.
50
+ *
51
+ * This is the mark lanes.sh serves as `/icon-light.svg` and `/icon-dark.svg`.
52
+ * It is *not* the glyph beside "Any MCP client" in the README diagram: that one
53
+ * is the Model Context Protocol's own logo, sitting in the client column next
54
+ * to the Anthropic and OpenAI marks, and shipping it here would have branded
55
+ * this endpoint with someone else's mark.
56
+ *
57
+ * Copied into `src/` rather than read from disk or fetched: the Dockerfile
58
+ * (`src/deployments/gcp/Dockerfile`) copies `package.json`, the lockfile,
59
+ * `bunfig.toml` and `src/` and nothing else, so a runtime read of a checked-in
60
+ * asset outside `src/` works from a checkout and returns nothing from every
61
+ * deployed target — the half of the bug that only shows up in production. A
62
+ * fetch would be worse: an icon that depends on the network resolving is an
63
+ * icon that is sometimes missing.
64
+ *
65
+ * **The ground is painted, not inherited.** The published pair leaves the gaps
66
+ * between the stripes transparent and lets the page supply the ground, which is
67
+ * right for a favicon on lanes.sh and wrong here: a client draws an icon on a
68
+ * surface this file knows nothing about, and transparent gaps mean the mark is
69
+ * a different thing on each one and invisible on some. Painting `#121214`
70
+ * behind the stripes is what makes the result the same icon everywhere, and it
71
+ * is why there is one entry below rather than two.
72
+ *
73
+ * The twenty-four stripes are generated rather than pasted. In the published
74
+ * file they are an exact arithmetic progression — same step on both axes, the
75
+ * export's own rounding never off by more than 0.02 units, which is under a
76
+ * thousandth of a pixel at 32px — so a loop says what twenty-four near-
77
+ * identical lines only imply.
78
+ */
79
+ function mark(): string {
80
+ const stripes: string[] = [];
81
+ for (let i = 0; i < 24; i += 1) {
82
+ const x = (345.141 + i * 203.2425).toFixed(3);
83
+ const y = (-4382.52 + i * 203.2425).toFixed(3);
84
+ stripes.push(
85
+ `<rect x="${x}" y="${y}" width="143.715" height="6661.17"` +
86
+ ` transform="rotate(45 ${x} ${y})" fill="${INK}" />`,
87
+ );
88
+ }
89
+
90
+ // One rect shape, spelled once and used twice: as the ground the stripes are
91
+ // painted over, and as the mask that stops them at its edge. Written apart
92
+ // they are two places to change a corner radius and one place to forget.
93
+ const tile = `x="${TILE.x}" y="${TILE.y}" width="${TILE.size}" height="${TILE.size}" rx="${TILE.radius}"`;
94
+
95
+ return [
96
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${HEIGHT}" role="img">`,
97
+ '<title>Lanes Link</title>',
98
+ `<rect ${tile} fill="${GROUND}" />`,
99
+ // The rounded square is a mask rather than a drawn shape, which is what
100
+ // makes the stripes stop where they do. Its id has to be stable but not
101
+ // unique — the icon is its own `data:` document, so nothing else is in
102
+ // scope to collide with it.
103
+ `<mask id="tile" maskUnits="userSpaceOnUse" x="0" y="0" width="${WIDTH}" height="${HEIGHT}">`,
104
+ `<rect ${tile} fill="#fff" />`,
105
+ '</mask>',
106
+ `<g mask="url(#tile)">${stripes.join('')}</g>`,
107
+ '</svg>',
108
+ ].join('');
109
+ }
110
+
111
+ /**
112
+ * Base64 rather than percent-encoding, and `Buffer` rather than `btoa`.
113
+ *
114
+ * Both forms are legal in a `data:` URI and the percent-encoded one is smaller
115
+ * and readable in a log, but every example in the spec and every client that
116
+ * has shipped support reads base64 — this is not the field to be clever in.
117
+ *
118
+ * `Buffer.from(…, 'utf8')` for the reason `src/connectivity/auth/basic/index.ts`
119
+ * gives: `btoa` throws above U+00FF and mangles what it does not throw on.
120
+ * Nothing above ASCII is in the mark today, but the failure mode of the day
121
+ * something is would be a corrupt icon rather than an error.
122
+ */
123
+ function dataUri(svg: string): string {
124
+ return `data:image/svg+xml;base64,${Buffer.from(svg, 'utf8').toString('base64')}`;
125
+ }
126
+
127
+ /**
128
+ * One entry, and deliberately no `theme`.
129
+ *
130
+ * `theme` says which UI a client should pick this icon *for*, so it is a claim
131
+ * that there is another one to pick instead. The icon carries its own ground
132
+ * and looks the same on either, and two identical entries labelled `light` and
133
+ * `dark` would be metadata describing a variation that does not exist. If a
134
+ * theme-specific mark is ever wanted, that is the point at which the field
135
+ * earns its place.
136
+ *
137
+ * `sizes: ['any']` is what the spec spells for a scalable icon. SVG is a SHOULD
138
+ * for clients and PNG a MUST, so a client could honour `icons` and still skip
139
+ * this one — a rasterised entry is the fix for that, and it can wait until a
140
+ * client we care about needs it, because a PNG blob checked in here is one
141
+ * nobody can regenerate from a clean checkout.
142
+ */
143
+ export const SERVER_ICONS: Icon[] = [
144
+ { src: dataUri(mark()), mimeType: 'image/svg+xml', sizes: ['any'] },
145
+ ];
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The MCP surface.
3
+ *
4
+ * Six files, each answering one question:
5
+ *
6
+ * visibility what this principal may see — policy, applied once
7
+ * naming how a capability id is spelled on the wire
8
+ * schema how a vendor's JSON Schema is made safe to publish
9
+ * routing how the profile and connection reach a resource or a prompt
10
+ * instructions what the endpoint says it is for, before any tool is called
11
+ * tools / resources / prompts how each capability kind is registered
12
+ * build the loop that puts them together
13
+ *
14
+ * It was one 685-line file, and the seam that mattered was invisible in it:
15
+ * everything in `visibility` decides *what* is exposed and everything else
16
+ * decides *how*. A discovery leak can only come from the first, which is now
17
+ * one file with one entry point.
18
+ */
19
+
20
+ export { buildMcpServer } from './build.ts';
21
+ export { serverInstructions } from './instructions.ts';
22
+ export { SERVER_NAME, capabilityIdForToolName, toolNameFor } from './naming.ts';
23
+ export { scopeResourceUri } from './routing.ts';
24
+ export { sanitizeSchema } from './schema.ts';
25
+ export {
26
+ mergeCapabilities,
27
+ oneProfile,
28
+ visibleCapabilities,
29
+ type BuildServerOptions,
30
+ type MergedCapability,
31
+ type ProfileRuntime,
32
+ } from './visibility.ts';