@plurnk/plurnk-mcp 1.26.0 → 1.28.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.
package/README.md CHANGED
@@ -155,7 +155,8 @@ so each search runs under the proposal policy.
155
155
  | MCP surface | Plurnk surface |
156
156
  |---|---|
157
157
  | Server tools | `worker:///_plurnk/tools/<server>.md` family summary |
158
- | Enabled tool | Exact `worker:///_plurnk/tools/<server>/<encoded-tool>.md` document and ````` ````server (tool) ````` |
158
+ | Complete tool definitions | `worker:///_plurnk/tools/<server>.json`: `{"tools":[...]}`, readable on demand with ordinary patterns |
159
+ | Enabled tool | `server (tool)` invocation in the family document; full input schema at `worker:///_plurnk/tools/<server>/<encoded-tool>.json` |
159
160
  | Resource catalog | `server:///` or `server:///resources` |
160
161
  | Resource | `server:///resources/<encoded-uri>` through ordinary `FIND` and `READ` |
161
162
  | Prompt catalog | `server:///prompts` |
@@ -182,14 +183,21 @@ client.
182
183
 
183
184
  HTTP servers support bearer references, client credentials, and interactive
184
185
  OAuth; stdio servers read their credentials from the environment. A server that
185
- needs interactive OAuth comes up `authorization-required`, and enabling it
186
- returns `202` with `definition.authorization.url` without
187
- publishing a partial server. The terminal client's `/mcp oauth <alias>` receives
188
- a configured HTTP loopback IP callback and opens the browser; supplying the
186
+ challenges an anonymous connection comes up `authorization-required`, without
187
+ publishing partial tools. A URL-only definition is sufficient; no callback
188
+ port needs to be configured. The terminal client's `/mcp oauth <alias>` binds
189
+ an available loopback port and calls `workspace.mcp.oauth.begin` before opening
190
+ the returned authorization URL. A fixed OAuth redirect, if configured, is
191
+ respected. Supplying the
189
192
  callback URL explicitly also works for remote/headless use. The client submits
190
- the complete URL through `workspace.mcp.oauth.complete`. PKCE,
193
+ the complete URL through `workspace.mcp.oauth.complete`. Its `202` acknowledges
194
+ sign-in; tools activate at the next safe workspace boundary, without another
195
+ callback submission. `list` shows their current readiness. PKCE,
191
196
  issuer and resource validation, refresh, scope escalation, and credentials
192
- remain inside the host connection.
197
+ remain inside the host connection. Callback addresses and credentials never
198
+ replace the saved definition. `enable` retries a connection; it does not replace
199
+ user sign-in. Rejected configured credentials are reported as authentication
200
+ failures rather than retryable server downtime.
193
201
 
194
202
  ## Verification
195
203
 
package/SPEC.md CHANGED
@@ -392,12 +392,13 @@ server needs, and its provenance names the registry and the server's `name@versi
392
392
  | The complete connection definition is invalid | `definition-invalid` | 400 |
393
393
  | The alias differs from the definition's name | `alias-mismatch` | 400 |
394
394
 
395
- Two protocol continuations remain MCP-registered workspace actions beneath the
395
+ Protocol continuations remain MCP-registered workspace actions beneath the
396
396
  common grammar:
397
397
 
398
398
  | Action | Parameters | Result / effect |
399
399
  |---|---|---|
400
- | `workspace.mcp.oauth.complete` | `alias`, `callbackUrl` | State- and issuer-validates one pending interactive callback through the SDK, completes connection preparation, and re-enables the alias through the coordinator ({§oauth-continuation}); the result is the common mutation result. |
400
+ | `workspace.mcp.oauth.begin` | `alias`, `redirectUrl` | Begin sign-in using a client-owned callback, without changing the connection definition; publish the pending URL through the coordinator. An active alias is returned unchanged. |
401
+ | `workspace.mcp.oauth.complete` | `alias`, `callbackUrl` | Validates and exchanges the pending callback through the SDK, returning `McpOAuthCompletionResult` (`202`, `alias`); ordinary capability refresh publishes the authenticated tools at the next safe boundary ({§oauth-continuation}). |
401
402
  | `workspace.mcp.complete` | `server`, `ref`, `argument`; optional `context` | Requests negotiated prompt/resource-template argument completion for a client-owned interaction. |
402
403
 
403
404
  Expected preparation failures cross the boundary as MCP-management Problems
@@ -408,6 +409,9 @@ Worker's own accepted mutation publishes them as unavailable
408
409
  | Endpoint condition | Problem |
409
410
  |---|---|
410
411
  | Cannot connect or complete discovery/catalog preparation at the negotiated revision | `502 server-unavailable`, retryable; names the server and its `type` without exposing credentials |
412
+ | HTTP 401 without configured credentials | Enabled `authorization-required`, no runtime and no URL until the client begins sign-in |
413
+ | HTTP 401 with configured credentials | `502 server-authentication-failed`, non-retryable, with `upstreamStatus: 401`; credentials are not replaced by interactive OAuth |
414
+ | Sign-in discovery lacks validated authorization metadata or a usable client-registration method | `502 oauth-metadata-unavailable` or `oauth-registration-unavailable`, non-retryable; retain the definition and surface the setup boundary, not retryable downtime |
411
415
  | The endpoint answers with a redirect | `502 server-redirected`, non-retryable ({§mcp-redirect-refused}) |
412
416
  | An operator setting of the alias is invalid | `422 server-settings-invalid`, non-retryable ({§mcp-server-settings}) |
413
417
  | Client-credentials grant rejected by the authorization server | `502 oauth-client-credentials-failed`, non-retryable; names the server and client id, never the secret ({§oauth-client-credentials}) |
@@ -416,21 +420,68 @@ Resource and prompt failures retain one caught remote diagnostic only through
416
420
  the executor-owned `PLURNK_EXECS_ERROR_DETAIL_LIMIT` bound; complete causes stay
417
421
  in daemon diagnostics. No MCP resource Problem admits an unbounded SDK message.
418
422
 
419
- §oauth-continuation Interactive preparation publishes the alias as enabled and
420
- `authorization-required` with its URL; it publishes no runtime. The adapter
421
- retains one pending candidate per `(workspace, alias)` holding the challenged
422
- connection and one workspace residency lease; a new challenge for the alias
423
- supersedes and releases the previous one ({§oauth-lifetime}).
424
- `oauth.complete` accepts the complete callback URL so state, `code`, and
425
- `iss` remain one parsing unit; it finishes the pending connection's
426
- authorization, prepares its active attachment, and re-enables the alias through
427
- the coordinator, whose publication consumes the prepared attachment and
428
- releases the lease. A callback for a superseded attempt fails as invalid; a
423
+ §oauth-continuation A URL-only HTTP definition is sufficient to reach the client
424
+ sign-in handoff. An unauthenticated 401 publishes `authorization-required` with
425
+ an empty `authorization` object, not retryable downtime. No browser is opened,
426
+ client is registered, or workspace lease retained merely for that challenge.
427
+ The client binds its callback before `oauth.begin`; absent an explicitly
428
+ configured redirect, it obtains an ephemeral loopback port (RFC 8252 §7.3).
429
+ The MCP SDK owns metadata discovery, registration, PKCE and token exchange.
430
+ An explicit OAuth definition may supply scope and client-registration metadata;
431
+ its optional fixed redirect is respected, never silently rewritten. Bearer,
432
+ client-credentials and Authorization-header configurations cannot be replaced
433
+ by `oauth.begin`. Callback addresses and acquired credentials are session state,
434
+ not changes to the persisted definition.
435
+
436
+ ```mermaid
437
+ sequenceDiagram
438
+ Model->>MCP: add URL-only definition
439
+ MCP-->>Model: authorization-required
440
+ User->>Client: sign in to alias
441
+ Client->>Client: bind callback
442
+ Client->>MCP: oauth.begin(alias, redirectUrl)
443
+ MCP-->>Client: authorization URL
444
+ Client->>User: open consent page
445
+ User->>Client: browser callback
446
+ Client->>MCP: oauth.complete(alias, callbackUrl)
447
+ MCP-->>Client: 202 sign-in accepted; activation pending
448
+ MCP-->>Model: active tools through normal publication
449
+ ```
450
+
451
+ `oauth.begin` prepares an authorization attempt without enabling the alias or
452
+ waiting for workspace quiescence. It returns `202` with an `authorization.url`
453
+ when consent is required, `202` without that field when an accepted connection
454
+ awaits publication, or `200` when tools are already active. These results name
455
+ the alias, not a capability snapshot. The adapter retains one pending candidate
456
+ per `(workspace, alias)` holding the connection and one workspace residency
457
+ lease. An unchanged publication preserves that independent attempt; a new
458
+ challenge supersedes it ({§oauth-lifetime}).
459
+ Concurrent begin requests for one alias are refused; a later begun attempt
460
+ supersedes the prior one unless its grant is already accepted and awaiting
461
+ publication. An in-flight begin cannot attach after disable,
462
+ remove or definition replacement. `oauth.complete` accepts the complete callback URL so state, `code`, and
463
+ `iss` remain one parsing unit. Grant acceptance returns without waiting for
464
+ workspace quiescence; it is not a configuration mutation and cannot re-enable
465
+ an alias. The existing catalog-refresh path prepares and publishes the current
466
+ enabled definitions, then consumes the candidate and releases its lease.
467
+ The accepted result does not claim tool readiness: `list` reports the current
468
+ published snapshot, including any subsequent preparation failure. A callback
469
+ for a superseded attempt fails as invalid; a
429
470
  committed attachment that no longer matches the pending definition fails
430
471
  with a conflict instead of replaying a stale snapshot. A missing, expired,
431
472
  mismatched, or replayed callback fails without exposing attacker-owned OAuth
432
473
  error text.
433
474
 
475
+ | Callback boundary | Result |
476
+ |---|---|
477
+ | Active workspace | Accept the valid grant without waiting for the held turn; queue publication through the coordinator's ordinary workspace gate. |
478
+ | Identical concurrent or accepted-but-unpublished callback | Share the same acceptance; exchange the one-time code once. |
479
+ | Different callback for that accepted attempt | Reject; never borrow another callback's acceptance. |
480
+ | Disable, remove, or replacement | Current definitions win; the old candidate cannot restore a withdrawn server. |
481
+ | Publication failure | Do not claim active tools; retain the accepted candidate for normal refresh/enable retry, without another code exchange. |
482
+ | Preparation failure published as unavailable | Preserve the preparation diagnostic; release the pending lease and close its unused connection. |
483
+ | Callback after publication | The attempt has been consumed; `404 oauth-not-pending`. |
484
+
434
485
  ## §oauth-lifetime Interactive OAuth lifetime and reauthorization
435
486
 
436
487
  Interactive OAuth state is deliberately ephemeral and process-memory: client
@@ -450,7 +501,7 @@ as an accidental failure.
450
501
  | Pending authorization | One pending candidate per `(workspace, alias)`; a new challenge or customized enable cancels and replaces it. A callback from a superseded attempt fails state validation instead of cross-completing. |
451
502
  | Client disconnect | Does not touch the pending candidate; it can still be completed, or replaced by a fresh request. |
452
503
  | Daemon restart during pending | The candidate is lost: nothing was durable, no attachment publishes, and `oauth.complete` answers `404 oauth-not-pending`. Start authorization again. |
453
- | Daemon restart after authorization | The durable definition rehydrates but tokens are gone; the attachment publishes `authorization-required` and enable returns status `202` with `definition.authorization.url` in the common mutation result. The operator reauthorizes. |
504
+ | Daemon restart after authorization | The durable definition rehydrates but tokens are gone; a challenged attachment publishes `authorization-required` and enable returns `202`. A URL is present only when a callback is already configured. The operator reauthorizes. |
454
505
  | Token expiry | An expired access token surfaces as one unauthorized response; the SDK re-acquires via `refresh_token` when one was issued, otherwise re-enters interactive authorization. |
455
506
  | Refresh | Happens only against the issuer bound during the original authorization; the refreshed token replaces the in-memory token. |
456
507
  | Workspace disable/remove | Closes the attachment and clears its pending candidate; no durable secret deletion is needed because nothing secret is durable. |
@@ -676,10 +727,11 @@ mediate a `postMessage` JSON-RPC `ui/` dialect, proxy app-initiated tool
676
727
  calls with consent, and own teardown. No Plurnk client can enforce that
677
728
  sandbox today (the terminal cannot), the daemon is not a second
678
729
  application platform, and AG-UI has no standard Apps projection — inventing
679
- a private event stream to carry Apps is rejected. Tool descriptions carrying
680
- `_meta.ui` metadata project into Plurnk without it: model-facing summaries
681
- derive only from name, description, title, and input schema, and no UI
682
- resource is fetched or preloaded. The capability matrix keeps the extension
730
+ a private event stream to carry Apps is rejected. Compact tool summaries and
731
+ invocations derive only from name, description, title, and input schema,
732
+ without `_meta.ui`. The on-demand raw tool catalog preserves `_meta` as inert
733
+ data: no UI resource is fetched or preloaded, permissions are not granted,
734
+ and reading a definition does not activate an App. The capability matrix keeps the extension
683
735
  non-advertised ({§mcp-capability-matrix}). Re-evaluate only when a
684
736
  sandbox-capable client exists and a standard AG-UI projection is agreed;
685
737
  even then the capability would be per-client-advertised, never daemon-wide.
@@ -702,6 +754,7 @@ it as a skill. The design review is tracked in #654.
702
754
  |---|---|
703
755
  | Server | One registered executor family, `worker:///_plurnk/tools/<server>.md`, and matching resource scheme |
704
756
  | Enabled tool | One annotated call in the compact family document plus one exact `worker:///_plurnk/tools/<server>/<encoded-tool>.json` input-contract document |
757
+ | Complete tool definitions | `worker:///_plurnk/tools/<server>.json`, containing `{"tools":[...]}` from the same effective snapshot |
705
758
  | Tool survey | Ordinary FIND summary metadata from the standard executable-tool resource tree |
706
759
  | Resource catalog | `<server>:///` and `<server>:///resources` |
707
760
  | Resources | `<server>:///resources` and encoded resource-URI descendants |
@@ -724,10 +777,20 @@ The compact family document contains annotated, copyable tool invocations with
724
777
  shallow required-field previews and alias-scoped schema links. Each linked child
725
778
  preserves the complete remote description and raw input schema, without
726
779
  reconstructing property tables or expanding nested constraints into the preview.
727
- Output schemas do not enter model teaching; the returned value remains ordinary evidence. Disabled names
780
+ Output schemas do not enter compact invocation teaching; the returned value remains ordinary evidence. Disabled names
728
781
  appear in no model teaching, and there is no MCP-specific FIND,
729
782
  READ, authority-root, or other model discovery mechanism for tools.
730
783
 
784
+ Each registry entry carries the original MCP `Tool` object as its
785
+ {§executor-tool-catalog} definition. The catalog preserves full descriptions,
786
+ input and output schemas, annotations, icons, and metadata as received through
787
+ the MCP client, without synthesized fields or schema expansion. It contains
788
+ the selected tools from all catalog pages, sorted by tool name, not the JSON-RPC
789
+ envelope, pagination cursors, or transport cache controls. The Markdown family
790
+ document links to it; ordinary READ and JSONPath can inspect one tool or the
791
+ whole catalog. Reading the catalog performs no additional MCP discovery and
792
+ does not inject its contents into turn 0.
793
+
731
794
  Core validates the exact target and the selected tool's invocation before
732
795
  effect admission. `McpExecutor.run()` independently rejects a target outside
733
796
  the same snapshot before issuing `tools/call`. The server's empty-authority
@@ -763,6 +826,12 @@ stdio/Streamable HTTP servers are composition evidence only.
763
826
  | `tool-reported-error` | 502 | The MCP tool reported an error (the tool call failed). |
764
827
  | `invalid-tool-arguments` | 400 | The tool arguments are not one JSON object. Recovery: One JSON object per MCP tool call; a second call is a second fence. |
765
828
  | `oauth-client-credentials-failed` | 502 | MCP server '*name*' rejected the client-credentials grant; check the configured client credentials and issuer. |
829
+ | `server-authentication-failed` | 502 | MCP server '*name*' rejected authentication (HTTP 401). |
830
+ | `oauth-metadata-unavailable` | 502 | MCP OAuth requires validated authorization-server metadata; legacy endpoint inference is not supported. |
831
+ | `oauth-registration-unavailable` | 502 | The authorization server exposes no usable client registration: configure pre-registration or advertised CIMD; its metadata does not advertise a Dynamic Client Registration endpoint. |
832
+ | `oauth-redirect-invalid` | 400 | The OAuth callback must use HTTPS or HTTP loopback with a usable port. |
833
+ | `oauth-configuration-conflict` | 409 | MCP server '*alias*' is not configured for this interactive OAuth callback. |
834
+ | `oauth-busy` | 409 | MCP server '*alias*' is already starting authorization. |
766
835
  | `parameters-invalid` | 400 | Unsupported parameter(s): *names*. |
767
836
  | `server-settings-invalid` | 422 | MCP server '*name*' has invalid operator settings: *cause*. |
768
837
  | `server-busy` | 409 | MCP server '*name*' has *n* active request(s). |
@@ -1 +1 @@
1
- {"version":3,"file":"Module.d.ts","sourceRoot":"","sources":["../src/Module.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"Module.d.ts","sourceRoot":"","sources":["../src/Module.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,0BAA0B,CAAC;AACvD,OAAO,KAAK,EACR,mBAAmB,EACnB,WAAW,EACd,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EACR,aAAa,EACb,gCAAgC,EAChC,+BAA+B,EAC/B,SAAS,EACT,YAAY,EACf,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAEH,KAAK,sBAAsB,EAC3B,KAAK,6BAA6B,EAClC,KAAK,0BAA0B,EAC/B,KAAK,yBAAyB,EAE9B,KAAK,wBAAwB,EAE7B,KAAK,qBAAqB,EAC1B,KAAK,8BAA8B,EAInC,KAAK,UAAU,EAEf,KAAK,2BAA2B,EACnC,MAAM,0BAA0B,CAAC;AAoBlC,OAAO,WAAkD,MAAM,kBAAkB,CAAC;AAoBlF,UAAU,kBAAkB;IACxB,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;IAClC,qBAAqB,CAAC,CAClB,OAAO,EAAE,gCAAgC,EACzC,GAAG,EAAE,SAAS,GACf,OAAO,CAAC,+BAA+B,CAAC,CAAC;IAC5C,IAAI,CAAC,CAAC,SAAS,EAAE,aAAa,EAAE,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;CAC1E;AAED,UAAU,mBAAmB;IACzB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC;IAC/B,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,QAAQ,CAAC,MAAM,CAAC,EAAE,kBAAkB,CAAC;CACxC;AAED,KAAK,mBAAmB,GAClB;IAAE,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAA;CAAE,GAC/B;IAAE,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAC7D;IAAE,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5F,UAAU,oBAAoB;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,gBAAgB,EAAE,UAAU,CAAC;IACtC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3E,QAAQ,CAAC,SAAS,CAAC,EAAE;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAClD,SAAS,CAAC,QAAQ,EAAE,2BAA2B,GAAG,OAAO,CAAC,SAAS,8BAA8B,EAAE,CAAC,CAAC;IACrG,oBAAoB,CAAC,CAAC,QAAQ,EAAE,2BAA2B,GAAG,SAAS,MAAM,EAAE,CAAC;IAChF,QAAQ,CAAC,KAAK,EAAE,0BAA0B,EAAE,QAAQ,EAAE,2BAA2B,GAAG,OAAO,CAAC,SAAS,sBAAsB,EAAE,CAAC,CAAC;IAC/H,KAAK,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,2BAA2B,GAAG,OAAO,CAAC,6BAA6B,CAAC,CAAC;IACrG,OAAO,CAAC,WAAW,EAAE,wBAAwB,GAAG,OAAO,CAAC,qBAAqB,CAAC,mBAAmB,CAAC,CAAC,CAAC;IACpG,QAAQ,CAAC,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE,2BAA2B,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClF,gBAAgB,CAAC,QAAQ,EAAE,2BAA2B,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1E;AAED,UAAU,eAAe;IACrB,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IAClE,iCAAiC,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAAC;IACnF,wBAAwB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,UAAU,KAAK,MAAM,CAAC,UAAU,CAAC,CAAC;IAE3G,mBAAmB,IAAI,MAAM,CAAC,UAAU,CAAC;IACzC,uBAAuB,CAAC,WAAW,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACtF,oBAAoB,CAAC,YAAY,EAAE;QAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,WAAW,GAAG,QAAQ,CAAC;QACrD,QAAQ,CAAC,SAAS,EAAE,UAAU,GAAG,MAAM,CAAC;QACxC,QAAQ,CAAC,WAAW,EAAE,UAAU,CAAC;QACjC,QAAQ,CAAC,YAAY,EAAE,UAAU,CAAC;QAClC,QAAQ,CAAC,OAAO,EAAE,CACd,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACzC,OAAO,EAAE,mBAAmB,KAC3B,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;KACnC,GAAG,IAAI,CAAC;IACT,4BAA4B,CAAC,OAAO,EAAE,oBAAoB,GAAG,yBAAyB,CAAC;CAC1F;AA0CD,MAAM,WAAW,aAAa;IAC1B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACpC;AAED,UAAU,kBAAkB;IACxB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAyGD,eAAO,MAAM,gBAAgB,gBACZ,SAAS,kBAAkB,EAAE,KAC3C,OAAO,CAAC,IAAI,CAUd,CAAC;AAkEF,MAAM,CAAC,OAAO,OAAO,MAAM;;IAsBvB,MAAM,CAAC,IAAI,CAAC,OAAO,GAAE,aAAkB,GAAG,MAAM,CAE/C;IAED,OAAO,eAEN;IAEK,KAAK,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAsDhD;IAijBD,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAGpB;CAeJ"}
package/dist/Module.js CHANGED
@@ -6,18 +6,19 @@
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { readDefinition } from "./definition.js";
8
8
  import { isDeepStrictEqual } from "node:util";
9
+ import { SdkHttpError, UnauthorizedError } from "@modelcontextprotocol/client";
9
10
  import { Problems, } from "@plurnk/plurnk-contracts";
10
11
  import ServerConnection, { AuthorizationRequiredError, isClientCredentialsRejection, McpRedirectError, } from "./client.js";
11
12
  import { configuredDefinitions, connectTimeoutMs, expandedServerNames, retryDelayMs, retryPacing, registrySettings, serverSettings, } from "./config.js";
12
13
  import { RegistryError, registryEntries, searchRegistry } from "./registry.js";
13
14
  import McpExecutor, { runtimeDecl, runtimeServerSummary } from "./McpExecutor.js";
14
15
  import McpResources from "./McpResources.js";
16
+ import { OAuthSetupError } from "./oauth.js";
15
17
  const OWNER = "@plurnk/plurnk-mcp";
16
18
  const FAMILY = "mcp";
17
19
  const NONEMPTY_STRING = { type: "string", minLength: 1 };
18
20
  const OPEN_OBJECT = { type: "object", additionalProperties: true };
19
21
  const MCP_DEFINITION = { $ref: "https://schemas.plurnk.xyz/v0/McpServerDefinition.json" };
20
- const MUTATION_RESULT = { $ref: "https://schemas.plurnk.xyz/v0/FunctionalityMutationResult.json" };
21
22
  const actionInput = (properties, required = []) => ({
22
23
  type: "object",
23
24
  additionalProperties: false,
@@ -56,6 +57,9 @@ const preparationError = (definition, authorization, cause, closeCause) => {
56
57
  const completeCause = closeCause === undefined
57
58
  ? cause
58
59
  : new AggregateError([cause, closeCause], `MCP server '${definition.name}' preparation and cleanup failed.`);
60
+ const setup = causeOf(cause, OAuthSetupError);
61
+ if (setup !== undefined)
62
+ return actionError(setup.code, 502, setup.message, { server: definition.name, retryable: false }, completeCause);
59
63
  // {§oauth-client-credentials} — a rejected grant is an authorization fact,
60
64
  // never a generic unavailability.
61
65
  if (authorization?.type === "client-credentials" && isClientCredentialsRejection(cause)) {
@@ -74,6 +78,10 @@ const preparationError = (definition, authorization, cause, closeCause) => {
74
78
  if (redirect !== undefined) {
75
79
  return actionError("server-redirected", 502, redirect.message, { server: definition.name, url: redirect.url, ...(redirect.location === null ? {} : { location: redirect.location }), retryable: false }, completeCause);
76
80
  }
81
+ const http = causeOf(cause, SdkHttpError);
82
+ if (http?.status === 401 || causeOf(cause, UnauthorizedError) !== undefined) {
83
+ return actionError("server-authentication-failed", 502, `MCP server '${definition.name}' rejected authentication (HTTP 401).`, { server: definition.name, upstreamStatus: 401, retryable: false }, completeCause);
84
+ }
77
85
  return actionError("server-unavailable", 502, `Configured MCP server '${definition.name}' is unavailable.`, {
78
86
  server: definition.name,
79
87
  type: definition.type,
@@ -143,6 +151,7 @@ export default class Module {
143
151
  #attachments = new Map();
144
152
  #identities = new Map();
145
153
  #pending = new Map();
154
+ #authorizing = new Set();
146
155
  #dirty = new Map();
147
156
  #connections = new Set();
148
157
  #refreshTimers = new Map();
@@ -181,12 +190,20 @@ export default class Module {
181
190
  refreshIfChanged: (identity) => this.#refreshIfChanged(identity),
182
191
  });
183
192
  // Protocol continuations beneath the common grammar.
193
+ seam.registerModuleAction({
194
+ name: "workspace.mcp.oauth.begin",
195
+ scope: "workspace",
196
+ residency: "required",
197
+ inputSchema: actionInput({ alias: NONEMPTY_STRING, redirectUrl: NONEMPTY_STRING }, ["alias", "redirectUrl"]),
198
+ outputSchema: { $ref: "https://schemas.plurnk.xyz/v0/McpOAuthBeginResult.json" },
199
+ handler: (params, context) => this.#beginOAuth(workspaceIdentityOf(context), params),
200
+ });
184
201
  seam.registerModuleAction({
185
202
  name: "workspace.mcp.oauth.complete",
186
203
  scope: "workspace",
187
204
  residency: "required",
188
205
  inputSchema: actionInput({ alias: NONEMPTY_STRING, callbackUrl: NONEMPTY_STRING }, ["alias", "callbackUrl"]),
189
- outputSchema: MUTATION_RESULT,
206
+ outputSchema: { $ref: "https://schemas.plurnk.xyz/v0/McpOAuthCompletionResult.json" },
190
207
  handler: (params, context) => this.#completeOAuth(workspaceIdentityOf(context), params),
191
208
  });
192
209
  seam.registerModuleAction({
@@ -299,7 +316,7 @@ export default class Module {
299
316
  throw new AggregateError(errors, "MCP connection shutdown failed");
300
317
  }
301
318
  // The attachment keeps the exact symbolic definition; resolved launch values never enter state.
302
- async #prepareAttachment(workspaceId, binding, connection) {
319
+ async #prepareAttachment(workspaceId, binding, connection, oauthRedirectUrl) {
303
320
  const { definition, context } = binding;
304
321
  this.#assertOpen();
305
322
  // {§mcp-server-settings} — the operator's settings for this alias; a bad one isolates this server.
@@ -319,6 +336,7 @@ export default class Module {
319
336
  // {§mcp-launch-environment} — a configured server inherits the operator's environment beneath the
320
337
  // workspace layer, as every MCP client launches one; the model's command ceiling is not its base.
321
338
  candidate ??= new ServerConnection(definition, environment(this.#env), {
339
+ ...(oauthRedirectUrl === undefined ? {} : { oauthRedirectUrl }),
322
340
  ...(definition.type === "stdio" && definition.cwd === undefined && context === undefined ? { cwd: await this.#stateDirectory(workspaceId, `${OWNER}/${definition.name}`) } : {}),
323
341
  ...(context === undefined ? {} : { plugin: context }),
324
342
  environment: environment(this.#operatorEnvironment()),
@@ -412,7 +430,7 @@ export default class Module {
412
430
  && force !== name
413
431
  && !this.#dirty.has(this.#pendingKey(workspaceId, name))
414
432
  && sameBinding(existing, binding)
415
- && !(pending?.prepared !== undefined)) {
433
+ && pending?.prepared === undefined && pending?.authorized !== true) {
416
434
  next.set(name, existing);
417
435
  continue;
418
436
  }
@@ -421,7 +439,7 @@ export default class Module {
421
439
  const catalogOnly = existing !== undefined
422
440
  && force !== name
423
441
  && sameBinding(existing, binding)
424
- && !(pending?.prepared !== undefined)
442
+ && pending?.prepared === undefined && pending?.authorized !== true
425
443
  && heldConnection !== undefined;
426
444
  if (pending?.prepared !== undefined && sameBinding(pending, binding)) {
427
445
  attachment = pending.prepared;
@@ -442,8 +460,9 @@ export default class Module {
442
460
  }
443
461
  }
444
462
  else {
463
+ const authorized = pending?.authorized === true && sameBinding(pending, binding);
445
464
  try {
446
- attachment = await this.#prepareAttachment(workspaceId, binding);
465
+ attachment = await this.#prepareAttachment(workspaceId, binding, authorized ? pending.connection : undefined);
447
466
  }
448
467
  catch (cause) {
449
468
  this.#assertOpen();
@@ -454,7 +473,9 @@ export default class Module {
454
473
  console.error(`MCP server '${name}' unavailable: ${refusal.problem.detail}`, refusal.cause ?? refusal);
455
474
  }
456
475
  // Only a connection this attempt opened is the attempt's to close on abort.
457
- if (attachment.kind !== "unavailable")
476
+ if (authorized)
477
+ consumedPending.set(name, pending);
478
+ else if (attachment.kind !== "unavailable")
458
479
  fresh.push(attachment);
459
480
  }
460
481
  next.set(name, attachment);
@@ -478,7 +499,10 @@ export default class Module {
478
499
  outcomes.set(name, { state: "unavailable", problem: attachment.problem });
479
500
  break;
480
501
  case "authorization-required":
481
- outcomes.set(name, { state: "authorization-required", authorization: { url: attachment.authorizationUrl } });
502
+ outcomes.set(name, {
503
+ state: "authorization-required",
504
+ authorization: attachment.authorizationUrl === undefined ? {} : { url: attachment.authorizationUrl },
505
+ });
482
506
  break;
483
507
  }
484
508
  }
@@ -507,15 +531,21 @@ export default class Module {
507
531
  .flatMap((attachment) => attachmentConnection(attachment) ?? [])
508
532
  .filter((connection) => !retained.has(connection) && !pendingConnections.has(connection));
509
533
  for (const [name, pending] of consumedPending) {
510
- this.#pending.delete(this.#pendingKey(workspaceId, name));
534
+ const key = this.#pendingKey(workspaceId, name);
535
+ if (this.#pending.get(key) === pending)
536
+ this.#pending.delete(key);
511
537
  pending.releaseWorkspace();
538
+ if (!retained.has(pending.connection))
539
+ obsolete.push(pending.connection);
512
540
  }
513
- // {§oauth-lifetime} Only the same pending connection can retain its authorization flow.
541
+ // {§oauth-lifetime} A client's challenge is independent of the published connection.
514
542
  for (const [key, pending] of this.#pending) {
515
543
  if (!key.startsWith(`${workspaceId}:`))
516
544
  continue;
517
- const current = next.get(key.slice(`${workspaceId}:`.length));
518
- if (current?.kind === "authorization-required" && current.connection === pending.connection)
545
+ const name = key.slice(`${workspaceId}:`.length);
546
+ const current = next.get(name);
547
+ if (current !== undefined && current.kind !== "active" && sameBinding(current, pending)
548
+ && force !== name)
519
549
  continue;
520
550
  this.#pending.delete(key);
521
551
  pending.releaseWorkspace();
@@ -523,24 +553,18 @@ export default class Module {
523
553
  obsolete.push(pending.connection);
524
554
  }
525
555
  for (const [name, attachment] of next) {
526
- if (attachment.kind !== "authorization-required")
556
+ if (attachment.kind !== "authorization-required" || attachment.authorizationUrl === undefined)
527
557
  continue;
528
558
  const key = this.#pendingKey(workspaceId, name);
529
559
  const current = this.#pending.get(key);
530
- if (current?.connection === attachment.connection)
560
+ if (current !== undefined)
531
561
  continue;
532
562
  this.#pending.set(key, {
533
563
  definition: attachment.definition,
534
564
  ...(attachment.context === undefined ? {} : { context: attachment.context }),
535
565
  connection: attachment.connection,
536
- authorizationUrl: attachment.authorizationUrl,
537
566
  releaseWorkspace: this.#retain(workspaceId),
538
567
  });
539
- if (current !== undefined) {
540
- current.releaseWorkspace();
541
- if (!retained.has(current.connection))
542
- obsolete.push(current.connection);
543
- }
544
568
  }
545
569
  if (obsolete.length > 0) {
546
570
  try {
@@ -573,9 +597,68 @@ export default class Module {
573
597
  this.#identities.delete(workspaceId);
574
598
  await this.#closeOwned(connections);
575
599
  }
576
- // {§oauth-continuation} — the callback finishes the pending connection's
577
- // authorization, prepares its attachment, and re-enables the alias through
578
- // the coordinator, which consumes the prepared attachment on publication.
600
+ // {§oauth-continuation} Client callback state travels with the prepared connection, not its definition.
601
+ async #beginOAuth(identity, params) {
602
+ assertActionKeys(params, ["alias", "redirectUrl"]);
603
+ const alias = requiredString(params, "alias");
604
+ const redirectUrl = requiredString(params, "redirectUrl");
605
+ const redirect = URL.parse(redirectUrl);
606
+ if (redirect === null || redirect.username || redirect.password || redirect.hash || redirect.port === "0"
607
+ || !(redirect.protocol === "https:" || (redirect.protocol === "http:" && ["127.0.0.1", "[::1]", "localhost"].includes(redirect.hostname)))) {
608
+ throw actionError("oauth-redirect-invalid", 400, "The OAuth callback must use HTTPS or HTTP loopback with a usable port.", { retryable: false });
609
+ }
610
+ const current = this.#attachments.get(identity.workspaceId)?.get(alias);
611
+ if (current === undefined)
612
+ throw actionError("server-not-connected", 409, `MCP server '${alias}' is not connected for this workspace.`, { retryable: false });
613
+ if (current.kind === "active")
614
+ return { status: 200, alias };
615
+ const definition = current.definition;
616
+ if (definition.type !== "streamable-http"
617
+ || (definition.authorization !== undefined && definition.authorization.type !== "oauth")
618
+ || Object.keys(definition.headers ?? {}).some((name) => name.toLowerCase() === "authorization")
619
+ || (definition.authorization?.redirectUrl !== undefined && definition.authorization.redirectUrl !== redirectUrl)) {
620
+ throw actionError("oauth-configuration-conflict", 409, `MCP server '${alias}' is not configured for this interactive OAuth callback.`, { retryable: false });
621
+ }
622
+ const key = this.#pendingKey(identity.workspaceId, alias);
623
+ if (this.#pending.get(key)?.authorized === true)
624
+ return { status: 202, alias };
625
+ if (this.#authorizing.has(key))
626
+ throw actionError("oauth-busy", 409, `MCP server '${alias}' is already starting authorization.`, { retryable: true });
627
+ this.#authorizing.add(key);
628
+ try {
629
+ const prepared = await this.#prepareAttachment(identity.workspaceId, current, undefined, redirectUrl);
630
+ if (prepared.kind === "unavailable")
631
+ throw new Error("OAuth preparation returned an unavailable attachment.");
632
+ if (this.#attachments.get(identity.workspaceId)?.get(alias) !== current) {
633
+ await this.#closeOwned([prepared.connection]);
634
+ throw actionError("oauth-target-conflict", 409, `MCP server '${alias}' changed while its OAuth authorization was pending.`, { workspaceId: identity.workspaceId, alias, recovery: "Start authorization again from the server's current definition.", retryable: false });
635
+ }
636
+ const previous = this.#pending.get(key);
637
+ const pending = {
638
+ ...prepared,
639
+ releaseWorkspace: this.#retain(identity.workspaceId),
640
+ ...(prepared.kind === "active" ? { prepared, authorized: true } : {}),
641
+ };
642
+ this.#pending.set(key, pending);
643
+ if (previous !== undefined) {
644
+ previous.releaseWorkspace();
645
+ if (previous.connection !== attachmentConnection(current))
646
+ await this.#closeOwned([previous.connection]);
647
+ }
648
+ if (prepared.kind === "authorization-required") {
649
+ if (prepared.authorizationUrl === undefined)
650
+ throw new Error("OAuth preparation with a callback omitted its authorization URL.");
651
+ return { status: 202, alias, authorization: { url: prepared.authorizationUrl } };
652
+ }
653
+ this.#dirty.set(key, Symbol());
654
+ this.#refreshCatalog(identity.workspaceId, alias);
655
+ return { status: 202, alias };
656
+ }
657
+ finally {
658
+ this.#authorizing.delete(key);
659
+ }
660
+ }
661
+ // {§oauth-continuation} — grant acceptance precedes ordinary catalog publication.
579
662
  async #completeOAuth(identity, params) {
580
663
  assertActionKeys(params, ["alias", "callbackUrl"]);
581
664
  const alias = requiredString(params, "alias");
@@ -588,23 +671,31 @@ export default class Module {
588
671
  throw actionError("oauth-not-pending", 404, `MCP server '${alias}' has no pending OAuth authorization.`, { workspaceId: identity.workspaceId, alias, retryable: false });
589
672
  }
590
673
  const current = this.#attachments.get(identity.workspaceId)?.get(alias);
591
- if (current === undefined || current.kind !== "authorization-required" || !sameBinding(current, pending)) {
674
+ if (current === undefined || !sameBinding(current, pending)) {
592
675
  throw actionError("oauth-target-conflict", 409, `MCP server '${alias}' changed while its OAuth authorization was pending.`, { workspaceId: identity.workspaceId, alias, recovery: "Start authorization again from the server's current definition.", retryable: false });
593
676
  }
594
- if (pending.prepared === undefined) {
677
+ if (pending.completion !== undefined) {
678
+ if (pending.completion.callbackUrl !== callbackUrl)
679
+ throw actionError("oauth-callback-invalid", 400, `OAuth authorization for MCP server '${alias}' could not be completed.`, { workspaceId: identity.workspaceId, alias, retryable: false });
680
+ return pending.completion.result;
681
+ }
682
+ const result = (async () => {
595
683
  try {
596
684
  await pending.connection.finishAuthorization(callbackUrl);
597
- const prepared = await this.#prepareAttachment(identity.workspaceId, pending, pending.connection);
598
- if (prepared.kind !== "active")
599
- throw new Error("OAuth completion returned another authorization challenge.");
600
- pending.prepared = prepared;
601
685
  }
602
686
  catch (cause) {
687
+ delete pending.completion;
603
688
  throw actionError("oauth-callback-invalid", 400, `OAuth authorization for MCP server '${alias}' could not be completed.`, { workspaceId: identity.workspaceId, alias, retryable: false }, cause);
604
689
  }
605
- }
606
- const result = await this.#handleOrThrow().invoke("enable", { alias }, identity);
607
- return result.body;
690
+ if (this.#pending.get(key) !== pending || this.#closed)
691
+ throw actionError("oauth-target-conflict", 409, `MCP server '${alias}' changed while its OAuth authorization was pending.`, { workspaceId: identity.workspaceId, alias, retryable: false });
692
+ pending.authorized = true;
693
+ this.#dirty.set(key, Symbol());
694
+ this.#refreshCatalog(identity.workspaceId, alias);
695
+ return { status: 202, alias };
696
+ })();
697
+ pending.completion = { callbackUrl, result };
698
+ return result;
608
699
  }
609
700
  async #complete(workspaceId, params) {
610
701
  assertActionKeys(params, ["server", "ref", "argument", "context"]);
@@ -635,25 +726,29 @@ export default class Module {
635
726
  const delay = retryDelayMs(retryPacing(this.#env), attempt);
636
727
  const timer = setTimeout(() => {
637
728
  this.#refreshTimers.delete(key);
638
- const identity = this.#identities.get(workspaceId);
639
- if (identity === undefined || this.#closed) {
640
- this.#dirty.delete(key);
641
- return;
642
- }
643
- void this.#handleOrThrow().refresh(identity).then(() => {
644
- if (this.#dirty.has(key))
645
- this.#scheduleCatalogRefresh(workspaceId, name, attempt + 1);
646
- }).catch((error) => {
647
- if (statusOf(error) === 409) {
648
- this.#scheduleCatalogRefresh(workspaceId, name, attempt + 1);
649
- return;
650
- }
651
- console.error(`MCP server '${name}' capability refresh failed:`, error);
652
- });
729
+ this.#refreshCatalog(workspaceId, name, attempt);
653
730
  }, delay);
654
731
  timer.unref();
655
732
  this.#refreshTimers.set(key, timer);
656
733
  }
734
+ #refreshCatalog(workspaceId, name, attempt = 0) {
735
+ const key = this.#pendingKey(workspaceId, name);
736
+ const identity = this.#identities.get(workspaceId);
737
+ if (identity === undefined || this.#closed) {
738
+ this.#dirty.delete(key);
739
+ return;
740
+ }
741
+ void this.#handleOrThrow().refresh(identity).then(() => {
742
+ if (this.#dirty.has(key))
743
+ this.#scheduleCatalogRefresh(workspaceId, name, attempt + 1);
744
+ }).catch((error) => {
745
+ if (statusOf(error) === 409) {
746
+ this.#scheduleCatalogRefresh(workspaceId, name, attempt + 1);
747
+ return;
748
+ }
749
+ console.error(`MCP server '${name}' capability refresh failed:`, error);
750
+ });
751
+ }
657
752
  #clearCatalogRefresh(key) {
658
753
  this.#dirty.delete(key);
659
754
  clearTimeout(this.#refreshTimers.get(key));