@plurnk/plurnk-mcp 1.25.0 → 1.27.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 +16 -6
- package/SPEC.md +91 -21
- package/dist/Module.d.ts.map +1 -1
- package/dist/Module.js +142 -47
- package/dist/Module.js.map +1 -1
- package/dist/ToolPresentation.d.ts.map +1 -1
- package/dist/ToolPresentation.js +1 -0
- package/dist/ToolPresentation.js.map +1 -1
- package/dist/client.d.ts +3 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +21 -14
- package/dist/client.js.map +1 -1
- package/dist/oauth.d.ts +4 -0
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +11 -2
- package/dist/oauth.js.map +1 -1
- package/docs/mcp.md +29 -7
- package/package.json +6 -6
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
|
-
|
|
|
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,12 +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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
192
|
+
callback URL explicitly also works for remote/headless use. The client submits
|
|
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,
|
|
189
196
|
issuer and resource validation, refresh, scope escalation, and credentials
|
|
190
|
-
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.
|
|
191
201
|
|
|
192
202
|
## Verification
|
|
193
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
|
-
|
|
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.
|
|
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,30 +420,78 @@ 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
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
437
488
|
registration data, access and refresh tokens, the PKCE verifier, and pending
|
|
438
489
|
state live only in the owning connection or pending candidate. Nothing
|
|
439
490
|
OAuth-secret is written to SQLite; the durable workspace state holds only the
|
|
440
|
-
unexpanded definition ({§mcp-configuration}). There is no callback HTTP
|
|
441
|
-
listener, authority-root resource, or daemon-side browser side channel:
|
|
442
|
-
client
|
|
491
|
+
unexpanded definition ({§mcp-configuration}). There is no daemon callback HTTP
|
|
492
|
+
listener, authority-root resource, or daemon-side browser side channel: an
|
|
493
|
+
interactive client may receive its configured loopback redirect, or accept a
|
|
494
|
+
pasted callback for remote/headless use. It returns the complete URL through `workspace.mcp.oauth.complete`
|
|
443
495
|
so `state`, `code`, and `iss` remain one parsing unit. Reauthorization after a
|
|
444
496
|
daemon restart is the intended journey, documented here rather than presented
|
|
445
497
|
as an accidental failure.
|
|
@@ -449,7 +501,7 @@ as an accidental failure.
|
|
|
449
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. |
|
|
450
502
|
| Client disconnect | Does not touch the pending candidate; it can still be completed, or replaced by a fresh request. |
|
|
451
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. |
|
|
452
|
-
| Daemon restart after authorization | The durable definition rehydrates but tokens are gone;
|
|
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. |
|
|
453
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. |
|
|
454
506
|
| Refresh | Happens only against the issuer bound during the original authorization; the refreshed token replaces the in-memory token. |
|
|
455
507
|
| Workspace disable/remove | Closes the attachment and clears its pending candidate; no durable secret deletion is needed because nothing secret is durable. |
|
|
@@ -675,10 +727,11 @@ mediate a `postMessage` JSON-RPC `ui/` dialect, proxy app-initiated tool
|
|
|
675
727
|
calls with consent, and own teardown. No Plurnk client can enforce that
|
|
676
728
|
sandbox today (the terminal cannot), the daemon is not a second
|
|
677
729
|
application platform, and AG-UI has no standard Apps projection — inventing
|
|
678
|
-
a private event stream to carry Apps is rejected.
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
resource is fetched or preloaded
|
|
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
|
|
682
735
|
non-advertised ({§mcp-capability-matrix}). Re-evaluate only when a
|
|
683
736
|
sandbox-capable client exists and a standard AG-UI projection is agreed;
|
|
684
737
|
even then the capability would be per-client-advertised, never daemon-wide.
|
|
@@ -701,6 +754,7 @@ it as a skill. The design review is tracked in #654.
|
|
|
701
754
|
|---|---|
|
|
702
755
|
| Server | One registered executor family, `worker:///_plurnk/tools/<server>.md`, and matching resource scheme |
|
|
703
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 |
|
|
704
758
|
| Tool survey | Ordinary FIND summary metadata from the standard executable-tool resource tree |
|
|
705
759
|
| Resource catalog | `<server>:///` and `<server>:///resources` |
|
|
706
760
|
| Resources | `<server>:///resources` and encoded resource-URI descendants |
|
|
@@ -723,10 +777,20 @@ The compact family document contains annotated, copyable tool invocations with
|
|
|
723
777
|
shallow required-field previews and alias-scoped schema links. Each linked child
|
|
724
778
|
preserves the complete remote description and raw input schema, without
|
|
725
779
|
reconstructing property tables or expanding nested constraints into the preview.
|
|
726
|
-
Output schemas do not enter
|
|
780
|
+
Output schemas do not enter compact invocation teaching; the returned value remains ordinary evidence. Disabled names
|
|
727
781
|
appear in no model teaching, and there is no MCP-specific FIND,
|
|
728
782
|
READ, authority-root, or other model discovery mechanism for tools.
|
|
729
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
|
+
|
|
730
794
|
Core validates the exact target and the selected tool's invocation before
|
|
731
795
|
effect admission. `McpExecutor.run()` independently rejects a target outside
|
|
732
796
|
the same snapshot before issuing `tools/call`. The server's empty-authority
|
|
@@ -762,6 +826,12 @@ stdio/Streamable HTTP servers are composition evidence only.
|
|
|
762
826
|
| `tool-reported-error` | 502 | The MCP tool reported an error (the tool call failed). |
|
|
763
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. |
|
|
764
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. |
|
|
765
835
|
| `parameters-invalid` | 400 | Unsupported parameter(s): *names*. |
|
|
766
836
|
| `server-settings-invalid` | 422 | MCP server '*name*' has invalid operator settings: *cause*. |
|
|
767
837
|
| `server-busy` | 409 | MCP server '*name*' has *n* active request(s). |
|
package/dist/Module.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Module.d.ts","sourceRoot":"","sources":["../src/Module.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"Module.d.ts","sourceRoot":"","sources":["../src/Module.ts"],"names":[],"mappings":"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"}
|
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:
|
|
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
|
-
&&
|
|
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
|
-
&&
|
|
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 (
|
|
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, {
|
|
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.#
|
|
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}
|
|
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
|
|
518
|
-
|
|
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
|
|
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}
|
|
577
|
-
|
|
578
|
-
|
|
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 ||
|
|
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.
|
|
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
|
-
|
|
607
|
-
|
|
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
|
-
|
|
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));
|