@lanes-sh/link 0.4.0 → 0.4.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, skills, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
@@ -1,8 +1,46 @@
1
- import { heading, print, style } from '../../output.ts';
1
+ import { emit, heading, print, style } from '../../output.ts';
2
2
  import { assetState, plannedAssets, readAsset } from './assets.ts';
3
3
  import { HARNESSES, type Harness } from './harnesses.ts';
4
4
  import { exists } from './register.ts';
5
5
 
6
+ /** Why a document this harness can hold is not what we ship, if it is not. */
7
+ export type DocumentState = 'current' | 'stale' | 'missing' | 'unreadable';
8
+
9
+ export interface ListedDocument {
10
+ readonly label: string;
11
+ readonly path: string;
12
+ readonly state: DocumentState;
13
+ /** Only for `unreadable` — what went wrong reading the bundled copy. */
14
+ readonly detail?: string;
15
+ }
16
+
17
+ export interface ListedHarness {
18
+ readonly id: string;
19
+ readonly label: string;
20
+ readonly installed: boolean;
21
+ /** The resolved binary, so a caller can see *which* claude answered. */
22
+ readonly binary: string | null;
23
+ readonly registered: boolean;
24
+ /**
25
+ * Empty when the harness is not installed, matching what the text rendering
26
+ * says: with no binary there is nothing to register the documents against, and
27
+ * reporting them would describe a setup that does not exist.
28
+ */
29
+ readonly documents: readonly ListedDocument[];
30
+ }
31
+
32
+ export interface McpListing {
33
+ readonly name: string;
34
+ readonly scope: string;
35
+ readonly harnesses: readonly ListedHarness[];
36
+ }
37
+
38
+ export interface McpListFlags {
39
+ readonly name?: string | undefined;
40
+ readonly scope?: string | undefined;
41
+ readonly json?: boolean | undefined;
42
+ }
43
+
6
44
  /**
7
45
  * `lanes link mcp list` — where this endpoint is registered, and whether what
8
46
  * each harness has been told about it is still what we ship.
@@ -11,58 +49,114 @@ import { exists } from './register.ts';
11
49
  * different fixes. A registration without the skill is a working endpoint
12
50
  * nobody reaches for; a skill against no registration is a document describing
13
51
  * tools that are not there.
52
+ *
53
+ * The listing is gathered before anything is printed, so `--json` and the text
54
+ * rendering describe the same snapshot rather than two probes taken a moment
55
+ * apart. `--json` exists because this is the one command another program has a
56
+ * reason to read: it is how a UI decides between "add" and "re-add", and the
57
+ * `stale` state is the only signal that a re-run would do something.
14
58
  */
15
- export async function mcpList(options: { name?: string | undefined; scope?: string | undefined }): Promise<void> {
59
+ export async function mcpList(options: McpListFlags): Promise<void> {
16
60
  const name = options.name ?? 'lanes-link';
17
61
  const scope = options.scope ?? 'user';
18
62
 
19
- heading(`Registered as ${style.bold(name)}`);
63
+ const listing = await listRegistrations(name, scope);
64
+
65
+ await emit(options.json, listing, () => render(listing));
66
+ }
67
+
68
+ /** The whole answer, gathered. No printing, so a caller can have it as data. */
69
+ export async function listRegistrations(name: string, scope: string): Promise<McpListing> {
70
+ const harnesses: ListedHarness[] = [];
20
71
 
21
72
  for (const harness of HARNESSES) {
22
73
  const binary = Bun.which(harness.binary);
23
74
 
24
75
  if (!binary) {
25
- print(` ${harness.label.padEnd(14)} ${style.dim('not installed')}`);
76
+ harnesses.push({
77
+ id: harness.id,
78
+ label: harness.label,
79
+ installed: false,
80
+ binary: null,
81
+ registered: false,
82
+ documents: [],
83
+ });
26
84
  continue;
27
85
  }
28
86
 
29
- print(
30
- ` ${harness.label.padEnd(14)} ${
31
- exists(binary, harness, name)
32
- ? style.green('registered')
33
- : style.dim(`not registered — lanes link mcp add ${harness.id}`)
34
- }`,
35
- );
36
-
37
- for (const line of await documentLines(harness, scope)) print(` ${' '.repeat(14)} ${line}`);
87
+ harnesses.push({
88
+ id: harness.id,
89
+ label: harness.label,
90
+ installed: true,
91
+ binary,
92
+ registered: exists(binary, harness, name),
93
+ documents: await documents(harness, scope),
94
+ });
38
95
  }
96
+
97
+ return { name, scope, harnesses };
39
98
  }
40
99
 
41
- /** One line per document this harness can hold, saying whether it is current. */
42
- async function documentLines(harness: Harness, scope: string): Promise<string[]> {
43
- const lines: string[] = [];
100
+ /** One entry per document this harness can hold, saying whether it is current. */
101
+ async function documents(harness: Harness, scope: string): Promise<ListedDocument[]> {
102
+ const listed: ListedDocument[] = [];
44
103
 
45
104
  for (const plan of plannedAssets(harness, scope)) {
46
105
  try {
47
- const state = await assetState(plan, await readAsset(plan.asset));
48
-
49
- lines.push(
50
- state === 'current'
51
- ? style.dim(`${plan.asset.label}: `) + style.green('up to date')
52
- : style.dim(
53
- state === 'stale'
54
- ? `${plan.asset.label}: out of date — lanes link mcp add ${harness.id}`
55
- : `${plan.asset.label}: not installed — lanes link mcp add ${harness.id}`,
56
- ),
57
- );
106
+ listed.push({
107
+ label: plan.asset.label,
108
+ path: plan.path,
109
+ state: await assetState(plan, await readAsset(plan.asset)),
110
+ });
58
111
  } catch (error) {
59
112
  // A checkout without `instructions/` — a container image, say. Worth one
60
113
  // line rather than a thrown error that hides the registration column.
61
- lines.push(style.dim(`${plan.asset.label}: ${message(error)}`));
114
+ listed.push({
115
+ label: plan.asset.label,
116
+ path: plan.path,
117
+ state: 'unreadable',
118
+ detail: message(error),
119
+ });
120
+ }
121
+ }
122
+
123
+ return listed;
124
+ }
125
+
126
+ function render(listing: McpListing): void {
127
+ heading(`Registered as ${style.bold(listing.name)}`);
128
+
129
+ for (const harness of listing.harnesses) {
130
+ if (!harness.installed) {
131
+ print(` ${harness.label.padEnd(14)} ${style.dim('not installed')}`);
132
+ continue;
133
+ }
134
+
135
+ print(
136
+ ` ${harness.label.padEnd(14)} ${
137
+ harness.registered
138
+ ? style.green('registered')
139
+ : style.dim(`not registered — lanes link mcp add ${harness.id}`)
140
+ }`,
141
+ );
142
+
143
+ for (const document of harness.documents) {
144
+ print(` ${' '.repeat(14)} ${documentLine(harness.id, document)}`);
62
145
  }
63
146
  }
147
+ }
64
148
 
65
- return lines;
149
+ function documentLine(id: string, document: ListedDocument): string {
150
+ switch (document.state) {
151
+ case 'current':
152
+ return style.dim(`${document.label}: `) + style.green('up to date');
153
+ case 'stale':
154
+ return style.dim(`${document.label}: out of date — lanes link mcp add ${id}`);
155
+ case 'missing':
156
+ return style.dim(`${document.label}: not installed — lanes link mcp add ${id}`);
157
+ case 'unreadable':
158
+ return style.dim(`${document.label}: ${document.detail}`);
159
+ }
66
160
  }
67
161
 
68
162
  function message(error: unknown): string {
package/src/cli/main.ts CHANGED
@@ -321,7 +321,11 @@ export async function run(argv: readonly string[]): Promise<void> {
321
321
  return mcpStdio({ ...global, ...(flags['only'] === true ? { only: true } : {}) });
322
322
  case 'list':
323
323
  case undefined:
324
- return mcpList({ name: text(flags, 'name'), scope: text(flags, 'scope') });
324
+ return mcpList({
325
+ name: text(flags, 'name'),
326
+ scope: text(flags, 'scope'),
327
+ json: flags['json'] === true,
328
+ });
325
329
  default:
326
330
  throw new Error(`Unknown: ${PROGRAM} mcp ${second}`);
327
331
  }
@@ -29,7 +29,9 @@ export const BUNQ_HINTS: Record<string, string> = {
29
29
  UPDATE_DraftPayment_for_User_MonetaryAccount:
30
30
  'Changes a draft that is still pending — status ACCEPTED sends it, REJECTED cancels it. ' +
31
31
  'previous_updated_timestamp is required and comes from reading the draft first; it is what stops two ' +
32
- 'callers acting on the same draft.',
32
+ 'callers acting on the same draft. Those two fields are the entire call — bunq refuses entries and ' +
33
+ 'number_of_required_accepts here as superfluous — so changing what a draft pays means rejecting it and ' +
34
+ 'creating another.',
33
35
 
34
36
  CREATE_PaymentBatch_for_User_MonetaryAccount:
35
37
  'Up to 350 payments in one call. Executes immediately, like a direct payment, and is all-or-nothing: bunq rejects ' +
@@ -51,14 +51,25 @@ export const BUNQ_REDACT: Record<string, string[]> = {
51
51
  'status',
52
52
  'schedule',
53
53
  ],
54
+ // Five, not seven: `entries` and `number_of_required_accepts` are no longer
55
+ // arguments at all. This is the one write here whose event cannot name an
56
+ // amount or a counterparty, because the call does not carry them — it says
57
+ // which draft was accepted and against which version of it, and that is
58
+ // everything there is to keep.
59
+ //
60
+ // Not everything a reader wants, though, and worth being honest that the gap
61
+ // is real rather than closed. The entries are on the event that *created* the
62
+ // draft, and nothing joins the two: an `AuditEvent` records arguments only,
63
+ // and bunq returns the draft id in the create's response. Reconstructing what
64
+ // an ACCEPTED draft paid means matching by hand. `context.audit.annotate`,
65
+ // which `gmail.send_message` uses to record resolved facts, is the shape of a
66
+ // fix and is a change to dispatch rather than to this list.
54
67
  UPDATE_DraftPayment_for_User_MonetaryAccount: [
55
68
  'userID',
56
69
  'monetary-accountID',
57
70
  'itemId',
58
71
  'status',
59
- 'entries',
60
72
  'previous_updated_timestamp',
61
- 'number_of_required_accepts',
62
73
  ],
63
74
  CREATE_PaymentBatch_for_User_MonetaryAccount: ['userID', 'monetary-accountID', 'payments'],
64
75
  };
@@ -144,7 +144,26 @@
144
144
  "content": {
145
145
  "application/json": {
146
146
  "schema": {
147
- "$ref": "#/components/schemas/DraftPayment"
147
+ "type": "object",
148
+ "description": "The whole of what this call takes. bunq refuses any other field here as superfluous — the rest of the schema it shares belongs to the call that creates one.",
149
+ "properties": {
150
+ "status": {
151
+ "type": "string",
152
+ "description": "The status of the DraftPayment.",
153
+ "readOnly": false,
154
+ "writeOnly": false
155
+ },
156
+ "previous_updated_timestamp": {
157
+ "type": "string",
158
+ "description": "The last updated_timestamp that you received for this DraftPayment. This needs to be provided to prevent race conditions.",
159
+ "readOnly": false,
160
+ "writeOnly": true
161
+ }
162
+ },
163
+ "required": [
164
+ "status",
165
+ "previous_updated_timestamp"
166
+ ]
148
167
  }
149
168
  }
150
169
  }
@@ -22,6 +22,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
22
22
  import { join } from 'node:path';
23
23
  import { OpenAPIToolGenerator, type McpOpenAPITool } from 'mcp-from-openapi';
24
24
  import { cutCycles, referenced, type Spec } from '../../shared/openapi.ts';
25
+ import { projectRequestBody } from '../../shared/vendor-operations.ts';
25
26
 
26
27
  const SOURCE = 'https://raw.githubusercontent.com/bunq/doc/master/swagger.json';
27
28
  const OUT = 'bunq.v1.json';
@@ -187,6 +188,34 @@ function dropReadOnly(node: unknown): number {
187
188
  return dropped;
188
189
  }
189
190
 
191
+ /**
192
+ * The operations bunq describes with the schema of a *different* operation.
193
+ *
194
+ * `PUT .../draft-payment/{itemId}` points at `DraftPayment`, the same schema as
195
+ * the `POST` that creates one, which requires `entries` and
196
+ * `number_of_required_accepts`. For a create those two *are* the payment. For an
197
+ * update bunq refuses both as superfluous — its own generated SDK sends only
198
+ * `status`, `previous_updated_timestamp` and `schedule` here — so the tool asked
199
+ * for exactly what the bank rejects and every accept failed. Worse than the
200
+ * error: made to send `entries` for a draft that has them, a model reaches for
201
+ * the array echoed back or for `[]`, and both ask bunq to rewrite what the draft
202
+ * pays on the way to approving it. A hint cannot fix a required argument, which
203
+ * is the difference between this and the `payments`-is-really-an-array note
204
+ * above: nothing stops an agent sending an array, and nothing lets it omit a
205
+ * required field. `required` is asserted here rather than projected because
206
+ * bunq's own is the one written for the create.
207
+ *
208
+ * `schedule` is deliberately not projected: it carries `recurrence_unit` and
209
+ * `recurrence_size`, so approving a one-off draft would be a place to acquire a
210
+ * standing order — the risk `OPERATIONS` gives for leaving the scheduling
211
+ * *endpoints* out. Not the whole of that risk, though: `CREATE_DraftPayment`
212
+ * still offers the same field, because it comes with bunq's create schema.
213
+ * Closing that changes what the provider can do rather than whether a call
214
+ * works, and is not done here.
215
+ */
216
+ const UPDATE_BODIES: Record<string, readonly string[]> = {
217
+ UPDATE_DraftPayment_for_User_MonetaryAccount: ['status', 'previous_updated_timestamp'],
218
+ };
190
219
 
191
220
  async function vendor(): Promise<void> {
192
221
  const response = await fetch(SOURCE);
@@ -246,6 +275,34 @@ async function vendor(): Promise<void> {
246
275
  }
247
276
 
248
277
  const schemas = spec.components?.schemas ?? {};
278
+
279
+ const projected = new Set<string>();
280
+ for (const item of Object.values(paths)) {
281
+ for (const [method, operation] of Object.entries(item)) {
282
+ if (!METHODS.includes(method)) continue;
283
+ const fields = operation.operationId ? UPDATE_BODIES[operation.operationId] : undefined;
284
+ if (!fields || !operation.operationId) continue;
285
+
286
+ projectRequestBody(
287
+ operation as unknown as Record<string, unknown>,
288
+ operation.operationId,
289
+ schemas,
290
+ fields,
291
+ 'The whole of what this call takes. bunq refuses any other field here as superfluous — the rest ' +
292
+ 'of the schema it shares belongs to the call that creates one.',
293
+ );
294
+ projected.add(operation.operationId);
295
+ }
296
+ }
297
+
298
+ // The same refusal as `missing` above, and for a sharper reason: an entry that
299
+ // matches nothing narrows nothing, and what is left is the wide body this
300
+ // exists to remove — printed as a success.
301
+ const unprojected = Object.keys(UPDATE_BODIES).filter((id) => !projected.has(id));
302
+ if (unprojected.length > 0) {
303
+ throw new Error(`UPDATE_BODIES names operations the spec does not have — ${unprojected.join(', ')}`);
304
+ }
305
+
249
306
  const keep = referenced(paths, schemas);
250
307
  const trimmedSchemas = Object.fromEntries(
251
308
  Object.entries(schemas).filter(([name]) => keep.has(name)),
@@ -295,7 +352,8 @@ async function vendor(): Promise<void> {
295
352
  ` bunq ${String(Object.keys(paths).length).padStart(2)} paths, ` +
296
353
  `${seen.size} operations, ${Object.keys(trimmedSchemas).length} schemas, ` +
297
354
  `${cuts} cycle${cuts === 1 ? '' : 's'} cut, ${headers} protocol params dropped, ` +
298
- `${readOnly} read-only fields dropped, ${size}KB`,
355
+ `${readOnly} read-only fields dropped, ${projected.size} update ` +
356
+ `bod${projected.size === 1 ? 'y' : 'ies'} projected, ${size}KB`,
299
357
  );
300
358
 
301
359
  await report(trimmed);
@@ -96,3 +96,84 @@ export function narrowRequestBody(
96
96
  body.content = Object.fromEntries(kept.map((type) => [type, body.content![type]]));
97
97
  return before.length - kept.length;
98
98
  }
99
+
100
+ /**
101
+ * Replace a request body with a projection of the schema it points at.
102
+ *
103
+ * The third surgery on this axis, and the first about the body being *wrong*
104
+ * rather than too wide. A document that describes two operations with one schema
105
+ * describes at least one of them wrongly, and `required` is where it bites: the
106
+ * generated tool asks for arguments the vendor refuses on the call they are
107
+ * attached to, so there is no correct call to make. It fails at the vendor, on
108
+ * every attempt, and nothing before the request can see it.
109
+ *
110
+ * Projecting rather than hand-writing is what keeps this tied to the document.
111
+ * The named fields are copied out of the vendor's own schema with their types
112
+ * and descriptions, so the tool still says what the vendor says. Everything this
113
+ * cannot verify it refuses instead: a field that is gone, a field the document
114
+ * marks read-only, a body offering a content type this does not rewrite, a
115
+ * `$ref` that does not point into `components.schemas`. A vendor refresh should
116
+ * fail loudly here rather than quietly restore the body it was called to fix.
117
+ *
118
+ * `required` is the one thing NOT projected — it is the caller's assertion, and
119
+ * necessarily so, since the whole disease is a `required` written for the other
120
+ * operation. Say why in the caller.
121
+ *
122
+ * `note` becomes the body schema's description. It lands in the committed
123
+ * document for whoever reads it there; it does **not** reach the agent, because
124
+ * the generator flattens body properties to the top level and drops the body
125
+ * schema's own description. The sentence an agent needs goes in `hints`.
126
+ *
127
+ * Apply before reachability, so a schema the projection no longer reaches leaves
128
+ * the document rather than lingering unused.
129
+ */
130
+ export function projectRequestBody(
131
+ operation: Record<string, unknown>,
132
+ operationId: string,
133
+ schemas: Record<string, unknown>,
134
+ fields: readonly string[],
135
+ note: string,
136
+ ): void {
137
+ const content = (operation['requestBody'] as { content?: Record<string, { schema?: { $ref?: string } }> })
138
+ ?.content;
139
+ const types = Object.keys(content ?? {});
140
+
141
+ const other = types.filter((type) => type !== 'application/json');
142
+ if (!content || types.length === 0 || other.length > 0) {
143
+ // A body left pointing at the wide schema on a second content type is the
144
+ // bug still present on whichever branch the generator happens to prefer.
145
+ throw new Error(
146
+ `${operationId}: expected a lone application/json request body to project, found ${types.join(', ') || 'none'}`,
147
+ );
148
+ }
149
+
150
+ const json = content['application/json'];
151
+ const reference = json?.schema?.$ref;
152
+ if (!json || typeof reference !== 'string' || !reference.startsWith('#/components/schemas/')) {
153
+ throw new Error(`${operationId}: request body is ${reference ?? 'not a $ref'}, not a schema reference`);
154
+ }
155
+
156
+ const name = reference.slice('#/components/schemas/'.length);
157
+ const source = (schemas[name] as { properties?: Record<string, unknown> } | undefined)?.properties ?? {};
158
+
159
+ const properties: Record<string, unknown> = {};
160
+ for (const field of fields) {
161
+ const property = source[field] as { readOnly?: boolean } | undefined;
162
+ if (!property) throw new Error(`${operationId}: ${name} no longer describes "${field}"`);
163
+ if (property.readOnly === true) {
164
+ // Projected fields are inlined into the path, where the read-only strip
165
+ // does not reach — so a field the vendor computes would survive here and
166
+ // be demanded as an argument it ignores.
167
+ throw new Error(`${operationId}: ${name}.${field} is read-only and cannot be a request field`);
168
+ }
169
+ properties[field] = property;
170
+ }
171
+
172
+ json.schema = {
173
+ type: 'object',
174
+ description: note,
175
+ properties,
176
+ required: [...fields],
177
+ } as never;
178
+ }
179
+