@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,186 @@
1
+ import { fromJsonSchema, type McpServer } from '@modelcontextprotocol/server';
2
+ import { z } from 'zod';
3
+ import { isToolResult } from '#connectivity';
4
+ import { toolNameFor } from './naming.ts';
5
+ import { resourceLinkRouter } from './routing.ts';
6
+ import { sanitizeSchema } from './schema.ts';
7
+ import { describeWithConnections, type BuildServerOptions, type MergedCapability } from './visibility.ts';
8
+
9
+ /**
10
+ * Tools — the capability kind everything else is measured against.
11
+ *
12
+ * Two registration paths and one handler. Discovered capabilities carry JSON
13
+ * Schema, because they come from an upstream MCP server or an OpenAPI document;
14
+ * local ones author Zod. Both end up as the same registered tool, with `profile`
15
+ * and `connection` injected either way — ADR-001 does not change just because
16
+ * the schema arrived differently.
17
+ */
18
+
19
+ /** The two arguments core injects, and no provider declares. */
20
+ function routingProperties(profiles: string[], connections: string[]): Record<string, unknown> {
21
+ return {
22
+ profile: {
23
+ type: 'string',
24
+ enum: profiles,
25
+ description: 'Which profile to act within',
26
+ },
27
+ connection: {
28
+ type: 'string',
29
+ enum: connections,
30
+ description: 'Which configured account to act on, within that profile',
31
+ },
32
+ };
33
+ }
34
+
35
+ export function registerDiscoveredTool(
36
+ server: McpServer,
37
+ id: string,
38
+ entry: MergedCapability,
39
+ options: BuildServerOptions,
40
+ ): void {
41
+ const discovered = entry.discovered!;
42
+ const profiles = [...entry.reachable.keys()];
43
+ const connections = [...new Set([...entry.reachable.values()].flat())];
44
+
45
+ const properties = (discovered.inputSchema['properties'] as Record<string, unknown>) ?? {};
46
+ const required = (discovered.inputSchema['required'] as string[]) ?? [];
47
+
48
+ server.registerTool(
49
+ toolNameFor(id),
50
+ {
51
+ ...(discovered.title ? { title: discovered.title } : {}),
52
+ description: describeWithConnections(discovered.description, entry.reachable),
53
+ // Spread the upstream schema rather than rebuilding it from properties
54
+ // and required alone. Vendors put `$defs` beside those and `$ref` into
55
+ // them — Linear's attachment tools do — and a rebuild drops the
56
+ // definitions while keeping the references, leaving a schema that cannot
57
+ // resolve itself. That failure only appears once a tool using it is
58
+ // actually registered, which is why a catch-all grant surfaced it and a
59
+ // read-only one never did.
60
+ inputSchema: fromJsonSchema(
61
+ sanitizeSchema({
62
+ ...discovered.inputSchema,
63
+ type: 'object',
64
+ properties: { ...properties, ...routingProperties(profiles, connections) },
65
+ required: [...required, 'profile', 'connection'],
66
+ }),
67
+ ),
68
+ },
69
+ makeHandler(id, entry, options),
70
+ );
71
+ }
72
+
73
+ export function registerLocalTool(
74
+ server: McpServer,
75
+ id: string,
76
+ entry: MergedCapability,
77
+ capability: Extract<NonNullable<MergedCapability['capability']>, { kind: 'tool' }>,
78
+ options: BuildServerOptions,
79
+ ): void {
80
+ const profiles = [...entry.reachable.keys()];
81
+ const connections = [...new Set([...entry.reachable.values()].flat())];
82
+ const shape = (capability.inputSchema as unknown as { shape?: z.ZodRawShape }).shape ?? {};
83
+
84
+ server.registerTool(
85
+ toolNameFor(id),
86
+ {
87
+ ...(capability.title ? { title: capability.title } : {}),
88
+ description: describeWithConnections(capability.description, entry.reachable),
89
+ inputSchema: {
90
+ ...shape,
91
+ // Injected by core, never declared by a provider — ADR-001. Both enums
92
+ // are built from resolved policy, so they double as the discovery
93
+ // filter.
94
+ profile: z.enum(profiles as [string, ...string[]]).describe('Which profile to act within'),
95
+ connection: z
96
+ .enum(connections as [string, ...string[]])
97
+ .describe('Which configured account to act on, within that profile'),
98
+ },
99
+ },
100
+ makeHandler(id, entry, options),
101
+ );
102
+ }
103
+
104
+ /**
105
+ * One tool handler, shared by local and discovered capabilities.
106
+ *
107
+ * `connection` is stripped here rather than inside a connector: it is injected
108
+ * by us and means nothing upstream, so forwarding it would leak our routing
109
+ * detail into someone else's API.
110
+ */
111
+ function makeHandler(capabilityId: string, entry: MergedCapability, options: BuildServerOptions) {
112
+ // `unknown` because the JSON-Schema overload types it that way; the schema
113
+ // has already validated the shape by the time this runs.
114
+ return async (args: unknown) => {
115
+ const { profile, connection, ...rest } = (args ?? {}) as Record<string, unknown>;
116
+
117
+ const name = String(profile);
118
+ const runtime = options.profiles.get(name);
119
+ const reachable = entry.reachable.get(name);
120
+
121
+ // The enums are a union across profiles, so a caller can name a valid
122
+ // profile and a connection that belongs to a different one. Refuse it here
123
+ // rather than dispatching: routing a `work` account through `personal`
124
+ // would cross exactly the boundary profiles exist to hold.
125
+ if (!runtime || !reachable) {
126
+ return {
127
+ content: [
128
+ {
129
+ type: 'text' as const,
130
+ text: `Profile "${name}" does not offer ${capabilityId}. Available: ${[...entry.reachable.keys()].join(', ')}`,
131
+ },
132
+ ],
133
+ isError: true,
134
+ };
135
+ }
136
+
137
+ if (!reachable.includes(String(connection))) {
138
+ return {
139
+ content: [
140
+ {
141
+ type: 'text' as const,
142
+ text: `Connection "${String(connection)}" is not part of profile "${name}". Available there: ${reachable.join(', ')}`,
143
+ },
144
+ ],
145
+ isError: true,
146
+ };
147
+ }
148
+
149
+ const outcome = await runtime.dispatcher.invoke({
150
+ principal: options.principal,
151
+ capabilityId,
152
+ connectionKey: String(connection),
153
+ arguments: rest,
154
+ clientLabel: options.clientLabel,
155
+ });
156
+
157
+ if (!outcome.ok) {
158
+ // A refusal is a tool error rather than a protocol error: the agent should
159
+ // be able to read it, explain it, and pick something else, not lose the
160
+ // connection.
161
+ return { content: [{ type: 'text' as const, text: outcome.message }], isError: true };
162
+ }
163
+
164
+ // A tool capability always produces a `ToolResult`; the guard is what makes
165
+ // that structural rather than assumed, now that dispatch can return three
166
+ // other shapes.
167
+ if (!isToolResult(outcome.result)) {
168
+ return {
169
+ content: [{ type: 'text' as const, text: `${capabilityId} is not a tool` }],
170
+ isError: true,
171
+ };
172
+ }
173
+
174
+ const result = outcome.result;
175
+ const route = resourceLinkRouter(runtime, capabilityId, name, String(connection));
176
+
177
+ return {
178
+ content: result.content.map((block) =>
179
+ block.type === 'text'
180
+ ? { type: 'text' as const, text: block.text }
181
+ : { type: 'resource_link' as const, uri: route(block.uri), name: block.name ?? block.uri },
182
+ ),
183
+ ...(result.isError ? { isError: true } : {}),
184
+ };
185
+ };
186
+ }
@@ -0,0 +1,132 @@
1
+ import type { Principal } from '#auth';
2
+ import type { Config } from '#profile';
3
+ import type { ProviderRegistry } from '#registry';
4
+ import type { Dispatcher } from '#dispatch';
5
+ import type { PolicyDocument } from '#policy';
6
+ import { allowedConnections } from '#policy';
7
+
8
+ /**
9
+ * What this principal can see, and therefore what gets registered at all.
10
+ *
11
+ * The server is a pure function of resolved policy: a capability the principal
12
+ * cannot reach on any connection is not registered, and one it can reach on some
13
+ * connections advertises exactly those in its `connection` enum. A client
14
+ * therefore cannot discover a connection it has no grant for.
15
+ *
16
+ * Discovery filtering and invocation enforcement share one implementation
17
+ * (`allowedConnections`, which calls the same `evaluate` the dispatcher uses).
18
+ * If they were computed separately they could drift, and a leak in discovery is
19
+ * still a leak.
20
+ */
21
+
22
+ /** Everything one profile contributes to the endpoint. */
23
+ export interface ProfileRuntime {
24
+ readonly config: Config;
25
+ readonly registry: ProviderRegistry;
26
+ readonly dispatcher: Dispatcher;
27
+ readonly policy: PolicyDocument;
28
+ readonly floor?: PolicyDocument | undefined;
29
+ /**
30
+ * Re-read the skills into `registry`, if they have changed on the store.
31
+ *
32
+ * Optional because only a served endpoint has one — a registry built to read
33
+ * manifests has nothing to refresh. Cheap and idempotent; the endpoint decides
34
+ * how often to ask (ADR-014).
35
+ */
36
+ refreshSkills?(): Promise<void>;
37
+ }
38
+
39
+ export interface BuildServerOptions {
40
+ /**
41
+ * Every profile this endpoint serves, keyed by name.
42
+ *
43
+ * One port, several profiles, and `profile` injected into each tool beside
44
+ * `connection`. What this trades away is worth naming: a token used to open
45
+ * exactly one profile, so a leaked one reached exactly one set of accounts.
46
+ * Now a single token reaches all of them and the *caller* chooses, which makes
47
+ * cross-profile access a matter of what the model decides to pass. Policy is
48
+ * still enforced per profile, and every call records which one.
49
+ */
50
+ readonly profiles: ReadonlyMap<string, ProfileRuntime>;
51
+ readonly principal: Principal;
52
+ /** Self-reported by the client. Recorded in audit; never used to authorize. */
53
+ readonly clientLabel?: string | undefined;
54
+ readonly version?: string;
55
+ }
56
+
57
+ /** One profile as the map the builder wants. */
58
+ export function oneProfile(
59
+ name: string,
60
+ runtime: ProfileRuntime,
61
+ ): ReadonlyMap<string, ProfileRuntime> {
62
+ return new Map([[name, runtime]]);
63
+ }
64
+
65
+ function connectionsOf(runtime: ProfileRuntime): string[] {
66
+ return runtime.config.connections.map((connection) => `${connection.provider}.${connection.id}`);
67
+ }
68
+
69
+ /**
70
+ * What each profile exposes of one capability, merged.
71
+ *
72
+ * A capability is registered once even when several profiles offer it — two
73
+ * mailboxes are still one `gmail.users.messages.list` tool — with the profile
74
+ * chosen per call. `reachable` stays per profile because the connection enum
75
+ * must not imply that an account of one profile can be used through another.
76
+ */
77
+ export interface MergedCapability {
78
+ readonly reachable: Map<string, string[]>;
79
+ readonly capability: ReturnType<ProviderRegistry['capabilities']>[number]['capability'];
80
+ readonly discovered: ReturnType<ProviderRegistry['capabilities']>[number]['discovered'];
81
+ }
82
+
83
+ export function mergeCapabilities(options: BuildServerOptions): Map<string, MergedCapability> {
84
+ const merged = new Map<string, MergedCapability>();
85
+
86
+ for (const [name, runtime] of options.profiles) {
87
+ const connections = connectionsOf(runtime);
88
+
89
+ for (const { id, capability, discovered } of runtime.registry.capabilities()) {
90
+ const reachable = allowedConnections(
91
+ id,
92
+ connections,
93
+ options.principal.id,
94
+ runtime.policy,
95
+ runtime.floor,
96
+ );
97
+ if (reachable.length === 0) continue;
98
+
99
+ const existing = merged.get(id);
100
+ if (existing) {
101
+ existing.reachable.set(name, reachable);
102
+ continue;
103
+ }
104
+
105
+ merged.set(id, { reachable: new Map([[name, reachable]]), capability, discovered });
106
+ }
107
+ }
108
+
109
+ return merged;
110
+ }
111
+
112
+ /** Which capability ids this principal can reach, across every profile served. */
113
+ export function visibleCapabilities(options: BuildServerOptions): string[] {
114
+ return [...mergeCapabilities(options).keys()];
115
+ }
116
+
117
+ /**
118
+ * Say which accounts are reachable, grouped by profile.
119
+ *
120
+ * Grouped rather than flattened because the two arguments are not independent:
121
+ * `profile: personal` with a connection belonging to `work` is refused, and a
122
+ * flat list would read as though any pairing were valid.
123
+ */
124
+ export function describeWithConnections(
125
+ description: string,
126
+ reachable: ReadonlyMap<string, readonly string[]>,
127
+ ): string {
128
+ const lines = [...reachable].map(
129
+ ([profile, connections]) => ` ${profile}: ${connections.join(', ')}`,
130
+ );
131
+ return `${description}\n\nAvailable connections, by profile:\n${lines.join('\n')}`;
132
+ }
@@ -0,0 +1,222 @@
1
+ import {
2
+ authorizationServerMetadata,
3
+ protectedResourceMetadata,
4
+ type AuthorizeRequest,
5
+ type OAuthResult,
6
+ type OAuthServer,
7
+ } from '#auth';
8
+ import { approvalPage } from '#cli/callback-page.ts';
9
+
10
+ /**
11
+ * The HTTP surface of the authorization flow.
12
+ *
13
+ * Its own file so the router stays a router: `index.ts` gains a delegation, the
14
+ * way it already delegates attachments, rather than six more branches and a
15
+ * form parser. It also keeps that file inside its size budget, which is the
16
+ * rule that would otherwise be relaxed to fit this in.
17
+ *
18
+ * Everything here is transport work — parse, dispatch, render. The decisions
19
+ * live in `#auth`, which is what makes the flow testable as a sequence of
20
+ * values rather than as a browser session.
21
+ */
22
+
23
+ export const PROTECTED_RESOURCE_PATH = '/.well-known/oauth-protected-resource';
24
+ export const AUTHORIZATION_SERVER_PATH = '/.well-known/oauth-authorization-server';
25
+ const REGISTER_PATH = '/register';
26
+ const AUTHORIZE_PATH = '/authorize';
27
+ const TOKEN_PATH = '/token';
28
+
29
+ export interface AuthorizationSurface {
30
+ /** Present only in `self` mode; `oidc` publishes metadata and issues nothing. */
31
+ readonly server?: OAuthServer | undefined;
32
+ /** Where a client should go to get a token — this origin, or an issuer's. */
33
+ readonly issuer: (origin: string) => string;
34
+ /** The MCP endpoint path, so `resource` names what the client actually calls. */
35
+ readonly mcpPath: string;
36
+ /**
37
+ * The target this endpoint runs as, so the consent page can name the store its
38
+ * token actually lives in. Credentials are per-target, and the reader is about
39
+ * to run a command in a shell that resolves a target of its own — usually
40
+ * `local`, which is the one store a deployed endpoint's token is never in.
41
+ */
42
+ readonly target: string;
43
+ }
44
+
45
+ /** Every path this surface answers, so the router can ask before authenticating. */
46
+ export function isAuthorizationPath(pathname: string): boolean {
47
+ return (
48
+ pathname === PROTECTED_RESOURCE_PATH ||
49
+ pathname.startsWith(`${PROTECTED_RESOURCE_PATH}/`) ||
50
+ pathname === AUTHORIZATION_SERVER_PATH ||
51
+ pathname === REGISTER_PATH ||
52
+ pathname === AUTHORIZE_PATH ||
53
+ pathname === TOKEN_PATH
54
+ );
55
+ }
56
+
57
+ /**
58
+ * The origin a client used to reach here.
59
+ *
60
+ * Not `request.url`, and not config. Cloud Run terminates TLS and forwards the
61
+ * original scheme in a header, so a URL built from the incoming request says
62
+ * `http` and every metadata document would name a resource no client asked for
63
+ * — which fails the exact-match the specification requires. Config cannot help
64
+ * either: the hostname carries a project hash assigned at deploy time.
65
+ */
66
+ export function publicOrigin(request: Request): string {
67
+ const url = new URL(request.url);
68
+ const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host;
69
+ const proto = request.headers.get('x-forwarded-proto') ?? url.protocol.replace(':', '');
70
+ return `${proto}://${host}`;
71
+ }
72
+
73
+ export function resourceMetadataUrl(request: Request): string {
74
+ return `${publicOrigin(request)}${PROTECTED_RESOURCE_PATH}`;
75
+ }
76
+
77
+ export async function handleAuthorization(
78
+ request: Request,
79
+ surface: AuthorizationSurface,
80
+ ): Promise<Response> {
81
+ const url = new URL(request.url);
82
+ const origin = publicOrigin(request);
83
+ const path = url.pathname;
84
+
85
+ // Both spellings: the bare document, and the one suffixed with the resource's
86
+ // own path, which is what a client probes first when the `401` pointed it
87
+ // nowhere. Answering both costs a comparison and removes a failure mode.
88
+ if (path === PROTECTED_RESOURCE_PATH || path.startsWith(`${PROTECTED_RESOURCE_PATH}/`)) {
89
+ return json(
90
+ protectedResourceMetadata({
91
+ resource: `${origin}${surface.mcpPath}`,
92
+ issuer: surface.issuer(origin),
93
+ }),
94
+ );
95
+ }
96
+
97
+ if (path === AUTHORIZATION_SERVER_PATH) {
98
+ // Only meaningful when this endpoint *is* the authorization server. Pointed
99
+ // at an external issuer, the client reads that issuer's document instead,
100
+ // and answering here with ours would send it to endpoints that do not exist.
101
+ if (!surface.server) return new Response('Not found', { status: 404 });
102
+ return json(authorizationServerMetadata(origin));
103
+ }
104
+
105
+ const server = surface.server;
106
+ if (!server) return new Response('Not found', { status: 404 });
107
+
108
+ if (path === REGISTER_PATH && request.method === 'POST') {
109
+ return render(await server.register(await safeJson(request)), request, surface.target);
110
+ }
111
+
112
+ if (path === AUTHORIZE_PATH) {
113
+ if (request.method === 'GET') {
114
+ return render(await server.authorize(url.searchParams), request, surface.target);
115
+ }
116
+ if (request.method === 'POST') {
117
+ const form = new URLSearchParams(await request.text());
118
+ return render(
119
+ await server.approve(requestFromForm(form), form.get('token') ?? ''),
120
+ request,
121
+ surface.target,
122
+ );
123
+ }
124
+ }
125
+
126
+ if (path === TOKEN_PATH && request.method === 'POST') {
127
+ return render(
128
+ await server.token(new URLSearchParams(await request.text())),
129
+ request,
130
+ surface.target,
131
+ );
132
+ }
133
+
134
+ return new Response('Method not allowed', { status: 405 });
135
+ }
136
+
137
+ function render(result: OAuthResult, request: Request, target: string): Response {
138
+ switch (result.kind) {
139
+ case 'json':
140
+ return json(result.body, result.status);
141
+
142
+ case 'redirect':
143
+ return new Response(null, { status: 302, headers: { location: result.location } });
144
+
145
+ case 'consent':
146
+ return approvalPage({
147
+ // The name if it gave one, the identifier if not. Either way the
148
+ // redirect host goes on the screen beside it — a client may call itself
149
+ // anything, but it cannot change where the code is sent.
150
+ client: result.clientName ?? result.request.clientId,
151
+ redirectHost: hostOf(result.request.redirectUri),
152
+ action: `${publicOrigin(request)}${AUTHORIZE_PATH}`,
153
+ fields: formFromRequest(result.request),
154
+ retry: result.retry,
155
+ target,
156
+ });
157
+
158
+ case 'error':
159
+ return new Response(result.message, { status: result.status });
160
+ }
161
+ }
162
+
163
+ /**
164
+ * The authorization request, carried through the approval form.
165
+ *
166
+ * Round-tripped through hidden fields rather than held in a server-side session:
167
+ * the deployed endpoint replaces instances between requests, so a session begun
168
+ * on one and submitted to another would be gone. Nothing here is a secret — the
169
+ * client sent all of it in the query string — and none of it is trusted on the
170
+ * way back, because `approve` re-checks the client and the redirect URI against
171
+ * what is registered before it mints anything.
172
+ */
173
+ function formFromRequest(request: AuthorizeRequest): Record<string, string> {
174
+ return {
175
+ client_id: request.clientId,
176
+ redirect_uri: request.redirectUri,
177
+ code_challenge: request.codeChallenge,
178
+ scope: request.scope,
179
+ ...(request.state !== undefined ? { state: request.state } : {}),
180
+ ...(request.resource !== undefined ? { resource: request.resource } : {}),
181
+ };
182
+ }
183
+
184
+ function requestFromForm(form: URLSearchParams): AuthorizeRequest {
185
+ return {
186
+ clientId: form.get('client_id') ?? '',
187
+ redirectUri: form.get('redirect_uri') ?? '',
188
+ codeChallenge: form.get('code_challenge') ?? '',
189
+ scope: form.get('scope') ?? '',
190
+ state: form.get('state') ?? undefined,
191
+ resource: form.get('resource') ?? undefined,
192
+ };
193
+ }
194
+
195
+ function hostOf(uri: string): string {
196
+ try {
197
+ return new URL(uri).host;
198
+ } catch {
199
+ return uri;
200
+ }
201
+ }
202
+
203
+ async function safeJson(request: Request): Promise<unknown> {
204
+ try {
205
+ return await request.json();
206
+ } catch {
207
+ return {};
208
+ }
209
+ }
210
+
211
+ function json(body: unknown, status = 200): Response {
212
+ return new Response(JSON.stringify(body), {
213
+ status,
214
+ headers: {
215
+ 'content-type': 'application/json',
216
+ // Discovery documents are public by definition and read on every fresh
217
+ // connection; `no-store` on the token endpoint is the part that matters,
218
+ // and it is the default for a POST anyway.
219
+ 'cache-control': 'no-store',
220
+ },
221
+ });
222
+ }
@@ -0,0 +1,53 @@
1
+ import {
2
+ hostHeaderValidationResponse,
3
+ localhostAllowedHostnames,
4
+ originValidationResponse,
5
+ } from '@modelcontextprotocol/server';
6
+
7
+ /**
8
+ * DNS-rebinding protection for a loopback endpoint.
9
+ *
10
+ * The attack this exists for needs no bug to work. An endpoint on 127.0.0.1 is
11
+ * reachable by any page the owner happens to be visiting: the attacker serves a
12
+ * short-TTL record, rebinds it to loopback, and the browser then calls this
13
+ * endpoint same-origin — at which point CORS stops applying and responses
14
+ * become readable. What it reaches is everything that answers before
15
+ * authentication, which is `/health`, the discovery documents, `/register`, and
16
+ * the `/authorize` consent form that asks for the owner's token.
17
+ *
18
+ * The check belongs here rather than in the MCP handler because the SDK's entry
19
+ * is documented as deliberately validation-free and expects this in front of
20
+ * it. The helpers are the SDK's for the same reason `quoted` is one function: a
21
+ * second implementation of a check is a second thing to drift.
22
+ *
23
+ * Only for loopback. A routable deployment gets nothing from this — rebinding
24
+ * to a public address buys an attacker no reach the address did not already
25
+ * give — and its hostname is assigned by the platform rather than known here,
26
+ * so a fixed allowlist would refuse every legitimate request instead.
27
+ */
28
+ export function allowedHostnamesFor(host: string, isLoopbackHost: boolean): string[] | undefined {
29
+ if (!isLoopbackHost) return undefined;
30
+
31
+ // The SDK's list rather than our own `LOOPBACK` set: this one has to match
32
+ // what a *browser* puts in a Host header, which means `[::1]` in brackets and
33
+ // every `*.localhost` name, not just the three spellings we bind to.
34
+ const allowed = localhostAllowedHostnames();
35
+ return allowed.includes(host) ? allowed : [...allowed, host];
36
+ }
37
+
38
+ /**
39
+ * The refusal, or `undefined` to let the request through.
40
+ *
41
+ * Host and Origin answer different questions — where the request thinks it is
42
+ * going, and where it came from — so both are checked. A missing Origin passes
43
+ * by design: no non-browser client sends one, and every MCP client is one.
44
+ */
45
+ export function rebindingRefusal(
46
+ request: Request,
47
+ allowedHostnames: readonly string[],
48
+ ): Response | undefined {
49
+ const allowed = [...allowedHostnames];
50
+ return (
51
+ hostHeaderValidationResponse(request, allowed) ?? originValidationResponse(request, allowed)
52
+ );
53
+ }