@covia/covia-sdk 1.18.0 → 1.19.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/dist/index.js CHANGED
@@ -37,6 +37,13 @@ var CoviaError = class extends Error {
37
37
  this.message = message;
38
38
  }
39
39
  };
40
+ var MCPError = class extends CoviaError {
41
+ constructor(message, code, data) {
42
+ super(message, code);
43
+ this.name = "MCPError";
44
+ this.data = data;
45
+ }
46
+ };
40
47
  var GridError = class extends CoviaError {
41
48
  constructor(statusCode, message, responseBody = null) {
42
49
  super(`HTTP ${statusCode}: ${message}`, statusCode);
@@ -1598,17 +1605,17 @@ async function* parseSSEStream(response, options) {
1598
1605
  // src/asset-cache.ts
1599
1606
  var PREFIX = "covia:asset-meta:";
1600
1607
  var LocalStorageMetadataStore = class {
1601
- get(hash) {
1608
+ get(key) {
1602
1609
  try {
1603
- const raw = localStorage.getItem(PREFIX + hash);
1610
+ const raw = localStorage.getItem(PREFIX + key);
1604
1611
  return raw ? JSON.parse(raw) : void 0;
1605
1612
  } catch {
1606
1613
  return void 0;
1607
1614
  }
1608
1615
  }
1609
- put(hash, metadata) {
1616
+ put(key, metadata) {
1610
1617
  try {
1611
- localStorage.setItem(PREFIX + hash, JSON.stringify(metadata));
1618
+ localStorage.setItem(PREFIX + key, JSON.stringify(metadata));
1612
1619
  } catch {
1613
1620
  }
1614
1621
  }
@@ -1641,6 +1648,15 @@ function getAssetMetadataStore() {
1641
1648
  function normaliseHash(hash) {
1642
1649
  return (hash.startsWith("0x") ? hash.slice(2) : hash).toLowerCase();
1643
1650
  }
1651
+ function persistentCacheKey(ref) {
1652
+ const parsed = parseDidUrl(ref);
1653
+ if (!parsed.did) return null;
1654
+ if (parsed.namespace !== Namespace.ASSET) return null;
1655
+ const hash = assetHash(ref);
1656
+ if (!hash) return null;
1657
+ const bare = normaliseHash(hash);
1658
+ return /^[0-9a-f]+$/.test(bare) ? `${parsed.did}/${Namespace.ASSET}/${bare}` : null;
1659
+ }
1644
1660
 
1645
1661
  // src/AdapterManager.ts
1646
1662
  var ADAPTERS_PATH = "v/info/adapters";
@@ -2458,6 +2474,14 @@ var AssetManager = class {
2458
2474
  * a mutable lattice path the venue resolves (`w/my-assets/foo`, `o/my-op`,
2459
2475
  * `<DID>/w/...`). Only content-addressed (immutable) refs are cached;
2460
2476
  * mutable paths are always re-fetched, so a changed value is never served stale.
2477
+ *
2478
+ * **Content-addressed refs are caller-relative, not global** (covia#502). A
2479
+ * bare hash or `a/<hash>` names the asset in *your own* `a/` namespace, so it
2480
+ * 404s for a hash someone else registered, and for an anonymous request.
2481
+ * Address another owner's asset explicitly as `<ownerDID>/a/<hash>`, and the
2482
+ * venue's own catalog as `<venueDID>/a/<hash>`. Only the DID-qualified form
2483
+ * is portable, so only it is eligible for the cross-session metadata store;
2484
+ * caller-relative refs are cached in memory on this manager alone.
2461
2485
  * @param assetId - Asset identifier or lattice address
2462
2486
  * @returns Returns either an Operation or DataAsset based on the asset's metadata
2463
2487
  */
@@ -2466,8 +2490,9 @@ var AssetManager = class {
2466
2490
  return this._wrap(assetId, this.cache.get(assetId));
2467
2491
  }
2468
2492
  const hash = assetHash(assetId);
2469
- if (hash) {
2470
- const persisted = getAssetMetadataStore()?.get(normaliseHash(hash));
2493
+ const storeKey = persistentCacheKey(assetId);
2494
+ if (storeKey) {
2495
+ const persisted = getAssetMetadataStore()?.get(storeKey);
2471
2496
  if (persisted) {
2472
2497
  this.cache.set(assetId, persisted);
2473
2498
  return this._wrap(assetId, persisted);
@@ -2481,7 +2506,7 @@ var AssetManager = class {
2481
2506
  );
2482
2507
  if (hash) {
2483
2508
  this.cache.set(assetId, data);
2484
- getAssetMetadataStore()?.put(normaliseHash(hash), data);
2509
+ if (storeKey) getAssetMetadataStore()?.put(storeKey, data);
2485
2510
  }
2486
2511
  return this._wrap(assetId, data);
2487
2512
  } catch (error) {
@@ -2717,6 +2742,7 @@ var OperationManager = class {
2717
2742
  // src/WorkspaceManager.ts
2718
2743
  var SCOPED_NAMESPACES = ["t", "n", "c"];
2719
2744
  var SCOPE_UNEXPANDED = /Cannot use '[tnc]\/' prefix outside/;
2745
+ var MAX_PROJECT_FIELDS = 16;
2720
2746
  function scopedNamespaceOf(path) {
2721
2747
  const head = path.split("/", 1)[0];
2722
2748
  return SCOPED_NAMESPACES.includes(head) ? head : void 0;
@@ -2761,6 +2787,11 @@ var WorkspaceManager = class {
2761
2787
  // selector-bearing read by complaining the shorthand has no execution scope,
2762
2788
  // after which scoped reads expand client-side instead.
2763
2789
  this.scopeExpansionSupported = true;
2790
+ // Whether this venue projects fields on `list` (covia#191). A venue without
2791
+ // it ignores the unknown `fields` param and answers a plain list, so the
2792
+ // probe is "asked for fields, got no values" — never a version check, which
2793
+ // covia-sdk#36 showed an embedded venue can misreport.
2794
+ this.fieldsSupported = true;
2764
2795
  }
2765
2796
  /** GET a job-free `/api/v1/values/{op}` read; omit undefined params. */
2766
2797
  _values(op, params) {
@@ -2850,6 +2881,76 @@ var WorkspaceManager = class {
2850
2881
  if (ucans?.length) return this.unsupported("UCAN-authorised workspace listings");
2851
2882
  return this.valuesRead("list", { path, limit, offset });
2852
2883
  }
2884
+ /**
2885
+ * List a node's children *and* read named subpaths of each one, in a single
2886
+ * round trip (covia#191) — the standard partial-response / sparse-fieldset
2887
+ * pattern, and the cure for the collection-view N+1 that list-then-read-each
2888
+ * forces on every page.
2889
+ *
2890
+ * ```ts
2891
+ * const page = await venue.workspace.listFields('j', ['status', 'meta/updated'], { limit: 50 });
2892
+ * page.values['<jobid>']['status']; // → { exists: true, value: 'COMPLETE' }
2893
+ * ```
2894
+ *
2895
+ * Each field is defined as a `read` of `<path>/<key>/<field>` and carries
2896
+ * single-read semantics verbatim: stored null is present, absent is
2897
+ * `{exists:false}`, and a value past `maxSize` withholds `value` and sets
2898
+ * `truncated`. Projection applies *after* the `limit`/`offset` key page, so
2899
+ * work is bounded by `limit × fields.length`.
2900
+ *
2901
+ * Output shape only — no filtering, no ordering. Filter the projected
2902
+ * records locally; for a recency subset use key design plus `slice`.
2903
+ *
2904
+ * On a venue that predates projection this falls back to `list()` plus a
2905
+ * bounded `read()` per (key, field), returning the identical shape, and
2906
+ * remembers not to ask that venue again.
2907
+ *
2908
+ * @param path Parent node. Must be a keyed node (map/Index) — a sequence or
2909
+ * scalar is a venue 400.
2910
+ * @param fields Subpaths to project per key; may be nested (`meta/updated`).
2911
+ * At most 16, the venue's cap.
2912
+ */
2913
+ async listFields(path, fields, opts = {}) {
2914
+ const { limit, offset, maxSize } = opts;
2915
+ if (fields.length === 0) throw new CoviaError("listFields needs at least one field");
2916
+ if (fields.length > MAX_PROJECT_FIELDS) {
2917
+ throw new CoviaError(
2918
+ `A fields projection may name at most ${MAX_PROJECT_FIELDS} subpaths; got ${fields.length}`
2919
+ );
2920
+ }
2921
+ if (this.fieldsSupported) {
2922
+ const result = await this.valuesRead(
2923
+ "list",
2924
+ { path, limit, offset, fields: fields.join(","), maxSize }
2925
+ );
2926
+ if (result.values) return result;
2927
+ if (!result.exists || result.keys === void 0) return { ...result, values: {} };
2928
+ this.fieldsSupported = false;
2929
+ }
2930
+ return this.projectClientSide(path, fields, { limit, offset, maxSize });
2931
+ }
2932
+ /**
2933
+ * The pre-covia#191 fallback: one `list`, then a `read` per (key, field).
2934
+ * Fan-out is bounded by the page the list already returned, so this costs
2935
+ * `keys.length × fields.length` reads for the page — exactly the N+1 the
2936
+ * server-side projection exists to remove, which is why it is the fallback
2937
+ * and not the default.
2938
+ */
2939
+ async projectClientSide(path, fields, opts) {
2940
+ const base = await this.valuesRead(
2941
+ "list",
2942
+ { path, limit: opts.limit, offset: opts.offset }
2943
+ );
2944
+ const keys = base.keys ?? [];
2945
+ const parent = path.replace(/\/+$/, "");
2946
+ const rows = await Promise.all(keys.map(async (key) => {
2947
+ const projected = await Promise.all(fields.map(async (field) => {
2948
+ return [field, await this.read(`${parent}/${key}/${field}`, opts.maxSize)];
2949
+ }));
2950
+ return [key, Object.fromEntries(projected)];
2951
+ }));
2952
+ return { ...base, values: Object.fromEntries(rows) };
2953
+ }
2853
2954
  async slice(path, offset, limit, ucans) {
2854
2955
  if (ucans?.length) return this.unsupported("UCAN-authorised workspace slices");
2855
2956
  return this.valuesRead("slice", { path, offset, limit });
@@ -3246,6 +3347,185 @@ var A2AManager = class {
3246
3347
  }
3247
3348
  };
3248
3349
 
3350
+ // src/MCPManager.ts
3351
+ var TOOL_CALL_OP = "v/ops/mcp/tools-call";
3352
+ var DEFAULT_MCP_PATH = "/mcp";
3353
+ var HEADER_SESSION_ID = "Mcp-Session-Id";
3354
+ function isResponseFrame(value) {
3355
+ return typeof value === "object" && value !== null && ("result" in value || "error" in value);
3356
+ }
3357
+ var MCPManager = class {
3358
+ constructor(venue) {
3359
+ this.venue = venue;
3360
+ this.nextId = 1;
3361
+ }
3362
+ /** The venue's MCP discovery document (`GET /.well-known/mcp`). */
3363
+ discovery() {
3364
+ return this.venue.mcpDiscovery();
3365
+ }
3366
+ /**
3367
+ * List the tools this venue bridges, job-free.
3368
+ *
3369
+ * @param options.cursor Continue a previous page. MCP pagination is opaque:
3370
+ * pass back the `nextCursor` a page returned, and stop when it is absent.
3371
+ */
3372
+ async listTools(options = {}) {
3373
+ const params = options.cursor !== void 0 ? { cursor: options.cursor } : {};
3374
+ const result = await this.request(
3375
+ "tools/list",
3376
+ params,
3377
+ { signal: options.signal }
3378
+ );
3379
+ const page = { tools: result?.tools ?? [] };
3380
+ if (result?.nextCursor !== void 0) page.nextCursor = result.nextCursor;
3381
+ return page;
3382
+ }
3383
+ /**
3384
+ * Every tool the venue bridges, draining the cursor.
3385
+ *
3386
+ * A server that never stops handing back a cursor would loop forever, so a
3387
+ * repeated cursor is treated as the end rather than trusted.
3388
+ */
3389
+ async listAllTools(options = {}) {
3390
+ const tools = [];
3391
+ const seen = /* @__PURE__ */ new Set();
3392
+ let cursor;
3393
+ for (; ; ) {
3394
+ const page = await this.listTools({ cursor, signal: options.signal });
3395
+ tools.push(...page.tools);
3396
+ if (page.nextCursor === void 0 || seen.has(page.nextCursor)) return tools;
3397
+ seen.add(page.nextCursor);
3398
+ cursor = page.nextCursor;
3399
+ }
3400
+ }
3401
+ /**
3402
+ * Call a bridged tool natively and return its MCP result.
3403
+ *
3404
+ * This is the direct protocol call: the answer comes back in the response,
3405
+ * and the caller never holds a Job. The venue still runs the tool through
3406
+ * its job machinery and records it — "direct" describes what *this* call
3407
+ * hands back, not whether the venue keeps a record.
3408
+ *
3409
+ * Use {@link callToolTracked} when you want the `Job` itself: to stream it,
3410
+ * inspect its steps, link to its receipt, or continue it when it pauses.
3411
+ *
3412
+ * An MCP tool that fails *its own* execution answers with `isError: true`
3413
+ * and the failure in `content` — a successful protocol call reporting a
3414
+ * failed tool, which is not thrown. Only a protocol-level error throws.
3415
+ */
3416
+ async callTool(name, args = {}, options = {}) {
3417
+ return this.request(
3418
+ "tools/call",
3419
+ { name, arguments: args },
3420
+ { signal: options.signal }
3421
+ );
3422
+ }
3423
+ /**
3424
+ * Call a tool through the `v/ops/mcp/tools-call` operation, returning the
3425
+ * `Job` so the run is an inspectable, linkable record.
3426
+ *
3427
+ * The operation is the venue's bridge to *any* MCP server, so `server`
3428
+ * defaults to this venue's own base URL — pointing the bridge back at the
3429
+ * venue, which is how a local tool gets called with a Job in hand. Name a
3430
+ * different `server` (with a `token` if it needs one) to reach a third-party
3431
+ * MCP server through the venue instead, which the native endpoint cannot do.
3432
+ *
3433
+ * Unlike {@link callTool} this always persists a job, so it belongs to a
3434
+ * user-driven execution, never a page load.
3435
+ */
3436
+ async callToolTracked(name, args = {}, options = {}) {
3437
+ const { server, token, ...invokeOptions } = options;
3438
+ const input = {
3439
+ server: server ?? this.venue.baseUrl,
3440
+ toolName: name,
3441
+ arguments: args
3442
+ };
3443
+ if (token !== void 0) input.token = token;
3444
+ return this.venue.operations.invoke(TOOL_CALL_OP, input, invokeOptions);
3445
+ }
3446
+ /**
3447
+ * Send an arbitrary JSON-RPC method to the venue's MCP endpoint — the escape
3448
+ * hatch for methods this manager does not wrap (`resources/list`,
3449
+ * `prompts/list`, …).
3450
+ *
3451
+ * Request ids are minted and correlated here, so callers never supply one.
3452
+ */
3453
+ async request(method, params = {}, options = {}) {
3454
+ const id = this.nextId++;
3455
+ const endpoint = await this.endpoint();
3456
+ const headers = {
3457
+ // The venue may answer either way; advertise both and branch on what
3458
+ // comes back rather than assuming JSON (which is the bug in every
3459
+ // hand-rolled copy of this call).
3460
+ Accept: "application/json, text/event-stream"
3461
+ };
3462
+ if (this.sessionId) headers[HEADER_SESSION_ID] = this.sessionId;
3463
+ const response = await venueStream(this.venue, endpoint, {
3464
+ method: "POST",
3465
+ headers,
3466
+ body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
3467
+ signal: options.signal
3468
+ });
3469
+ const session = response.headers?.get?.(HEADER_SESSION_ID);
3470
+ if (session) this.sessionId = session;
3471
+ const frame = (response.headers?.get?.("Content-Type") ?? "").includes("text/event-stream") ? await this.readSSEFrame(response, id, options.signal) : await response.json();
3472
+ return this.unwrap(frame, method, id);
3473
+ }
3474
+ /** The resolved MCP endpoint path, from discovery, probed once. */
3475
+ endpoint() {
3476
+ return this.endpointPromise ?? (this.endpointPromise = this.resolveEndpoint());
3477
+ }
3478
+ /**
3479
+ * Discovery names the endpoint; `${baseUrl}/mcp` is the compatibility
3480
+ * default. Discovery failing is not fatal — a venue that serves `/mcp`
3481
+ * without advertising it still works, so a failed probe falls through to the
3482
+ * default rather than failing the call that triggered it.
3483
+ */
3484
+ async resolveEndpoint() {
3485
+ try {
3486
+ const doc = await this.discovery();
3487
+ const advertised = [doc?.server_url, typeof doc?.endpoint === "string" ? doc.endpoint : void 0].find((v) => typeof v === "string" && v.length > 0);
3488
+ if (advertised) {
3489
+ if (/^https?:\/\//i.test(advertised)) {
3490
+ return advertised.startsWith(this.venue.baseUrl) ? advertised.slice(this.venue.baseUrl.length) || DEFAULT_MCP_PATH : advertised;
3491
+ }
3492
+ return advertised.startsWith("/") ? advertised : `/${advertised}`;
3493
+ }
3494
+ } catch {
3495
+ }
3496
+ return DEFAULT_MCP_PATH;
3497
+ }
3498
+ /**
3499
+ * Read the response frame from an SSE-framed answer: the first event whose
3500
+ * JSON-RPC id matches the request. Notifications and unrelated frames are
3501
+ * skipped rather than mistaken for the answer.
3502
+ */
3503
+ async readSSEFrame(response, id, signal) {
3504
+ for await (const event of parseSSEStream(response, signal ? { signal } : void 0)) {
3505
+ let payload;
3506
+ try {
3507
+ payload = event.json();
3508
+ } catch {
3509
+ continue;
3510
+ }
3511
+ if (isResponseFrame(payload) && payload.id === id) return payload;
3512
+ }
3513
+ throw new CoviaError(
3514
+ `MCP stream ended without a response to request ${id}`
3515
+ );
3516
+ }
3517
+ /** JSON-RPC envelope → result, or a typed error. */
3518
+ unwrap(frame, method, id) {
3519
+ if (frame?.error) {
3520
+ throw new MCPError(frame.error.message || `MCP ${method} failed`, frame.error.code, frame.error.data);
3521
+ }
3522
+ if (!frame || !("result" in frame)) {
3523
+ throw new CoviaError(`MCP ${method} returned no result for request ${id}`);
3524
+ }
3525
+ return frame.result;
3526
+ }
3527
+ };
3528
+
3249
3529
  // src/Agent.ts
3250
3530
  var Agent = class _Agent {
3251
3531
  constructor(id, venue) {
@@ -3381,6 +3661,21 @@ function asCoviaError(error) {
3381
3661
  const message = error instanceof Error ? error.message : String(error);
3382
3662
  return new CoviaError(message);
3383
3663
  }
3664
+ function canonicalDidFromDocument(didDoc) {
3665
+ const akas = didDoc.alsoKnownAs;
3666
+ if (Array.isArray(akas)) {
3667
+ const keyDid = akas.find((a) => typeof a === "string" && a.startsWith("did:key:"));
3668
+ if (keyDid) return keyDid;
3669
+ }
3670
+ const methods = didDoc.verificationMethod;
3671
+ if (Array.isArray(methods)) {
3672
+ for (const method of methods) {
3673
+ const multibase = method?.publicKeyMultibase;
3674
+ if (typeof multibase === "string" && multibase.length > 0) return `did:key:${multibase}`;
3675
+ }
3676
+ }
3677
+ return void 0;
3678
+ }
3384
3679
  function assertVenueIdentity(expectedDid, actualDid, baseUrl) {
3385
3680
  if (expectedDid && actualDid && expectedDid !== actualDid) {
3386
3681
  throw new VenueIdentityChangedError(expectedDid, actualDid, baseUrl);
@@ -3463,6 +3758,9 @@ var Venue = class _Venue {
3463
3758
  get users() {
3464
3759
  return this._users ?? (this._users = new UserManager(this));
3465
3760
  }
3761
+ get mcp() {
3762
+ return this._mcp ?? (this._mcp = new MCPManager(this));
3763
+ }
3466
3764
  get a2a() {
3467
3765
  return this._a2a ?? (this._a2a = new A2AManager(this));
3468
3766
  }
@@ -3504,12 +3802,14 @@ var Venue = class _Venue {
3504
3802
  if (typeof venueId === "string") {
3505
3803
  let candidates;
3506
3804
  let expectedDid;
3805
+ let requestedWebDid;
3507
3806
  if (venueId.startsWith("did:web:")) {
3508
- expectedDid = venueId;
3509
3807
  const didDoc = await resolver.resolve(venueId);
3510
3808
  if (!didDoc.didDocument) {
3511
3809
  throw new CoviaError("Invalid DID document");
3512
3810
  }
3811
+ expectedDid = canonicalDidFromDocument(didDoc.didDocument);
3812
+ requestedWebDid = venueId;
3513
3813
  const endpoint = didDoc.didDocument.service?.find((service) => service.type === "Covia.API.v1")?.serviceEndpoint;
3514
3814
  if (typeof endpoint !== "string") {
3515
3815
  throw new CoviaError("No (string) endpoint found for DID");
@@ -3530,7 +3830,8 @@ var Venue = class _Venue {
3530
3830
  if (!data.did) {
3531
3831
  throw new CoviaError(`Venue status at ${statusUrl} did not include a DID`);
3532
3832
  }
3533
- assertVenueIdentity(expectedDid, data.did, baseUrl);
3833
+ const required = expectedDid ?? (data.did.startsWith("did:web:") ? requestedWebDid : void 0);
3834
+ assertVenueIdentity(required, data.did, baseUrl);
3534
3835
  return new _Venue({
3535
3836
  baseUrl,
3536
3837
  venueId: data.did,
@@ -3830,6 +4131,8 @@ exports.JobManager = JobManager;
3830
4131
  exports.JobNotFoundError = JobNotFoundError;
3831
4132
  exports.JobStatus = JobStatus;
3832
4133
  exports.KeyPairAuth = KeyPairAuth;
4134
+ exports.MCPError = MCPError;
4135
+ exports.MCPManager = MCPManager;
3833
4136
  exports.MemoryManager = MemoryManager;
3834
4137
  exports.Namespace = Namespace;
3835
4138
  exports.NoAuth = NoAuth;