@webpieces/mcp-server 0.4.990 → 0.4.992

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +199 -42
  2. package/package.json +7 -6
  3. package/src/McpApiDispatcher.d.ts +6 -3
  4. package/src/McpApiDispatcher.js +6 -2
  5. package/src/McpApiDispatcher.js.map +1 -1
  6. package/src/McpAuth.d.ts +115 -56
  7. package/src/McpAuth.js +180 -111
  8. package/src/McpAuth.js.map +1 -1
  9. package/src/McpAuthorityCompileAssertions.d.ts +12 -2
  10. package/src/McpAuthorityCompileAssertions.js +32 -4
  11. package/src/McpAuthorityCompileAssertions.js.map +1 -1
  12. package/src/McpBindOptions.d.ts +5 -20
  13. package/src/McpBindOptions.js +5 -15
  14. package/src/McpBindOptions.js.map +1 -1
  15. package/src/McpClientSet.d.ts +35 -0
  16. package/src/McpClientSet.js +86 -0
  17. package/src/McpClientSet.js.map +1 -0
  18. package/src/McpDoorAuth.d.ts +117 -0
  19. package/src/McpDoorAuth.js +455 -0
  20. package/src/McpDoorAuth.js.map +1 -0
  21. package/src/McpToolCallRendering.d.ts +5 -4
  22. package/src/McpToolCallRendering.js.map +1 -1
  23. package/src/McpToolCatalog.d.ts +1 -1
  24. package/src/McpToolCatalog.js +1 -1
  25. package/src/McpToolCatalog.js.map +1 -1
  26. package/src/McpToolRegistry.js +8 -2
  27. package/src/McpToolRegistry.js.map +1 -1
  28. package/src/McpToolset.d.ts +115 -0
  29. package/src/McpToolset.js +323 -0
  30. package/src/McpToolset.js.map +1 -0
  31. package/src/WpMcpErrorTranslator.d.ts +5 -7
  32. package/src/WpMcpErrorTranslator.js +7 -13
  33. package/src/WpMcpErrorTranslator.js.map +1 -1
  34. package/src/WpMcpInMemory.d.ts +30 -0
  35. package/src/WpMcpInMemory.js +97 -0
  36. package/src/WpMcpInMemory.js.map +1 -0
  37. package/src/WpMcpRemote.d.ts +23 -0
  38. package/src/WpMcpRemote.js +89 -0
  39. package/src/WpMcpRemote.js.map +1 -0
  40. package/src/WpMcpServer.d.ts +45 -76
  41. package/src/WpMcpServer.js +101 -327
  42. package/src/WpMcpServer.js.map +1 -1
  43. package/src/__tests__/McpTransportFixtures.d.ts +107 -0
  44. package/src/__tests__/McpTransportFixtures.js +266 -0
  45. package/src/__tests__/McpTransportFixtures.js.map +1 -0
  46. package/src/index.d.ts +9 -2
  47. package/src/index.js +17 -1
  48. package/src/index.js.map +1 -1
package/README.md CHANGED
@@ -80,15 +80,163 @@ tool receives exactly one input object, so `McpToolRegistry` refuses `@WpMcpTool
80
80
  at boot. A tool therefore cannot claim to be
81
81
  read-only while its endpoint declares `WRITE` — the contradiction is unrepresentable.
82
82
 
83
- ## Booting: the server is constructed with the generated catalogs
83
+ ## One toolset, two transports
84
+
85
+ The TOOLS and their POLICY are the same for every caller; only the way in differs. So the package is
86
+ split in two layers:
87
+
88
+ - **`McpToolset`** — the shared core: WHICH tools are served (bindings, catalogs, profiles), the
89
+ per-caller `AuthorizedMcpTools` projection, input/output schema validation, dispatch through
90
+ `binding.invoke`, and error translation. Its ONLY input about the caller is the CURRENT
91
+ `RequestContext` — the trusted USER_ID / USER_ROLES / ORG_ID and a `VerifiedMachineCaller`, exactly what
92
+ `AuthorizationService.authorize` reads. `@WpAuthorization` on the contract is the single policy everywhere.
93
+ - **a transport** — whose only job is to establish that context.
94
+
95
+ ```ts
96
+ const toolset = new McpToolset(
97
+ [McpApiBinding.local(StoresApi, router)], // McpApiBinding.local / .remote, unchanged
98
+ McpToolCatalog.fromPackages(['@myorg/my-apis'], __dirname),
99
+ router.authorizationService(),
100
+ ['user'], // #1187 profiles: WHICH tools this toolset serves
101
+ );
102
+ ```
103
+
104
+ | transport | the caller is | use |
105
+ |---|---|---|
106
+ | `WpMcpInMemory.connect(toolset)` | whoever is trusted in the `RequestContext` of the code calling the client | an in-process agent that runs its own model loop (ai-chat) |
107
+ | `new WpMcpServer(config, toolset).bind(app, options)` | whoever ONE of `config.setAuth([...])`'s modes verified | a service-to-service hop, a customer's AI, an off-the-shelf connector |
108
+
109
+ ### In-memory
110
+
111
+ ```ts
112
+ const client: Client = await WpMcpInMemory.connect(toolset); // MCP SDK Client, InMemoryTransport pair
113
+ // inside a request whose caller the endpoint's AuthFilter already verified:
114
+ const { tools } = await client.listTools();
115
+ await client.callTool({ name: 'stores_pause', arguments: { storeId } });
116
+ ```
117
+
118
+ There is no credential. The caller is READ PER REQUEST from the `RequestContext` of the code calling
119
+ `listTools()` / `callTool()`, and NEVER captured at connect: apps cache one client per process, so a
120
+ connect-time capture would run user B's call as user A. The SDK delivers each in-memory message
121
+ synchronously inside the caller's own `send` (as a JSON copy, exactly what the wire would carry), so the
122
+ toolset's handlers run in the caller's async context. A call made outside any `RequestContext` is refused,
123
+ never run as nobody. The tool runs in the caller's own scope, so a local binding's
124
+ `InvocationAuthentication` finds the verified ingress the endpoint's `AuthFilter` completed there. No
125
+ tools/list cache hint is sent, so the shared client never serves one caller's list to another.
126
+
127
+ ### HTTP
128
+
129
+ One server = one path = one toolset (one profile set). `/mcp/user` and `/mcp/admin` are two servers over
130
+ two toolsets — paths split by WHICH TOOLS, never by HOW THE CALLER AUTHENTICATES.
131
+
132
+ ### Several clients: `McpClientSet`
133
+
134
+ An agent may hold several clients — its in-memory toolset plus another service's remote endpoint. Tool
135
+ names are flat across servers, so `McpClientSet.merge([...])` refuses a name two clients publish
136
+ (`McpDuplicateToolError`, at connect time and again on every `listTools()`, because each server projects
137
+ per caller) and routes `callTool` by name.
138
+
139
+ ## HTTP authentication: an OR-set of the existing `@WpAuth` modes, plus `mcpOAuth(...)`
140
+
141
+ There is no second auth vocabulary. The server takes the same builders endpoints use, verified by the
142
+ same hooks the endpoint `AuthFilter` uses (`router.credentialVerifiers()`):
143
+
144
+ ```ts
145
+ new WpMcpServerConfig()
146
+ .setName(...).setTitle(...).setDescription(...).setWebsiteUrl(...).setVersion(...)
147
+ .setResource('https://api.example.com/mcp/user')
148
+ .setAuth([oidcSameProject()]) // now: internal only
149
+ .setCredentialVerifiers(router.credentialVerifiers());
150
+ // .setAuth([oidcSameProject(), jwt(), apiKey('partner', creds), // later: customers' AI
151
+ // mcpOAuth(authority, ['https://login.example.com'], ['tools'],
152
+ // McpAccessTokenFormat.opaquePrefix('wpmcp_'))])
153
+ ```
154
+
155
+ | mode | claims a request carrying | use |
156
+ |---|---|---|
157
+ | `oidcSameProject()` / `oidcSvcs([...])` | `Bearer` with a Google `iss` (or an off-GCP `dev-oidc.` token) | internal svc-to-svc MCP; the only modes (with `sharedSecret`) whose forwarded trusted context is believed |
158
+ | `jwt()` | every other `Bearer` no other configured mode claims | the app's `JwtHook` — our own user token, or a customer agent holding it |
159
+ | `apiKey(regime, creds)` | its own declared header(s) | a customer's custom agent |
160
+ | `sharedSecret(key)` | `Authorization: Webpieces <secret>` | internal deployments that chose it |
161
+ | `mcpOAuth(...)` **(new)** | `Bearer` of the shape its `McpAccessTokenFormat` declares | off-the-shelf connectors (Claude / ChatGPT): RFC 9728 metadata, 401 `WWW-Authenticate` discovery, audience = canonical resource |
162
+ | `vendorAuth(...)` / public | — | refused: public does not compile in `setAuth([...])`, `vendorAuth(...)` is refused at startup — neither identifies a caller to authorize tools against |
163
+
164
+ `mcpOAuth(authority, authorizationServers, requiredScopes, tokenFormat, maxAccountValidationAgeSeconds?)`
165
+ holds everything OAuth-only — the former `setAccessTokenAuthority`, `setAuthorizationServers`,
166
+ `setRequiredScopes` and `setMaxAccountValidationAgeSeconds` setters, which are DELETED. They are
167
+ required only when the mode is in the set. `tokenFormat` says how the authority's tokens look:
168
+ `McpAccessTokenFormat.opaquePrefix('wpmcp_')`, `.jwtIssuer()` (a JWT whose `iss` is one of
169
+ `authorizationServers`), or `.everyBearer()` (legal only when no other bearer mode is in the set).
170
+ `WpMcpServer.protectedResourceMetadata()` exists only on a server with `mcpOAuth(...)`.
171
+
172
+ ### Classify, then verify with exactly ONE verifier
173
+
174
+ Credentials are self-describing, so one path serves every mode. The request is classified from
175
+ UNVERIFIED surface facts — which header; a JWT's unverified `iss` — and handed to exactly ONE verifier.
176
+ Classifying on unverified data is safe because the chosen verifier fully verifies: a token that merely
177
+ claims `iss: accounts.google.com` fails Google's verification and the request is a 401. It never falls
178
+ through to another mode.
179
+
180
+ - No claimant: an `mcpOAuth(...)` server answers with the discovery challenge; any other server a plain 401.
181
+ - Two claimants (an api-key header AND a bearer): ambiguous, refused — never "pick the one that works".
182
+ - One claimant: its rejection IS the answer. An authenticated caller lacking a role gets the normal
183
+ authorization refusal (a hidden tool), never a retry as another mode.
184
+ - Only `mcpOAuth(...)` emits `WWW-Authenticate` discovery; every other rejection is a plain 401 with no
185
+ hint of which mode came closest.
186
+ - `claims()` sets are disjoint BY CONSTRUCTION, and `bind(...)` refuses a set that could overlap
187
+ (`mcpOAuth(..., everyBearer())` or a bearer `apiKey(...)` beside another bearer mode, an
188
+ `mcpOAuth(..., jwtIssuer())` trusting a Google issuer beside an OIDC mode), a mode whose verifier is
189
+ not bound (naming the binding line), and `vendorAuth` / public.
190
+ - Trusted context and surface (`llm`) come from the mode that authenticated. Inbound trusted headers are
191
+ reconciled exactly as on ordinary routes: believed only from a verified OIDC / shared-secret hop, and on
192
+ every other mode admitted only when the credential itself vouched for the same value.
193
+
194
+ ### `WpMcpRemote`: the client for the internal door
195
+
196
+ ```ts
197
+ const client = await WpMcpRemote.connect('https://svc-b.example.com/mcp/user', oidcSameProject(), gcpOidc);
198
+ ```
199
+
200
+ An MCP SDK `Client` whose transport, PER POST, mints an OIDC ID token for the callee origin (as
201
+ `http-client-node` does — `gcpOidc` is `GcpOidc` from `@webpieces/gcp-identity`) and forwards the current
202
+ `RequestContext`'s transferable keys, trusted ones included, so the callee's `@WpAuthorization` judges the
203
+ user on whose behalf THIS call is made. Per POST for the same cached-client reason as in-memory; it
204
+ caches no response either.
205
+
206
+ ## Endpoints need NO new auth for MCP
207
+
208
+ The MCP credential never becomes a downstream credential: a local binding passes a token-free
209
+ `InvocationAuthentication` through the ordinary filter chain, and a remote binding uses the contract's
210
+ own `oidcSameProject()` / `oidcSvcs(...)` / `sharedSecret(...)`. So ONE method serves an in-process agent
211
+ (in-memory) and a customer's LLM (HTTP `jwt()` / OAuth) unchanged — add `jwt()` to `@WpAuth` only if a
212
+ GUI also calls the endpoint directly:
213
+
214
+ ```ts
215
+ @WpAuth([oidcSameProject()])
216
+ @WpAuthorization({ authType: AuthorizationType.ROLES, roles: ['OPERATOR', 'MEALCO_OPS'] })
217
+ @Endpoint(POST, '/pause-store', WRITE, RPC)
218
+ @WpMcpTool('stores_pause', { profiles: ['user'] })
219
+ ```
220
+
221
+ ### `SERVICE_ONLY` can never be an MCP tool
222
+
223
+ `AuthorizationType.SERVICE_ONLY` admits a bare service hop — current-hop machine evidence and nothing
224
+ about a user. An MCP call is always made on behalf of a MODEL, so such a policy is wrong in both
225
+ directions: behind an HTTP door with a user-token mode every model call is denied, and behind an OIDC door
226
+ or an in-memory client running in an OIDC-ingressed request the service hop itself would satisfy it —
227
+ admitting every model behind that service. So `McpToolset` REFUSES to build when a `@WpMcpTool` declares
228
+ `SERVICE_ONLY`, naming the method. Use `USERS_OR_SERVICES`, `ALL_USERS` or `ROLES` on anything you mark
229
+ `@WpMcpTool`.
230
+
231
+ ## Booting: the toolset is constructed with the generated catalogs
84
232
 
85
233
  The api library's build (its `openapi-generate` target — tag the library `generate:openapi`) writes one
86
234
  `mcp-<ContractClass>-tools.json` per MCP contract into its build output, beside the OpenAPI documents.
87
- Hand them to `McpBindOptions` with ONE call that works in every environment:
235
+ Hand them to `McpToolset` with ONE call that works in every environment:
88
236
 
89
237
  ```ts
90
238
  const catalogs = McpToolCatalog.fromPackages(['@myorg/my-apis'], __dirname);
91
- server.bind(app, new McpBindOptions(path, bindings, catalogs, McpDeployment.singleProcess()));
239
+ const toolset = new McpToolset(bindings, catalogs, router.authorizationService(), [Mcp.DEFAULT]);
92
240
  ```
93
241
 
94
242
  `fromPackages` resolves each package from `__dirname` the way node does. A BUILT or published package
@@ -98,7 +246,7 @@ vitest) holds a `project.json`, and the files are read from the outputPath of th
98
246
  `openapi-generate` dependsOn (its `build`) — which is why nx.json's targetDefaults for a server's `test`
99
247
  (and `build`) must dependsOn `^openapi-generate`; `validate-nx-wiring` prints the exact line.
100
248
 
101
- `McpToolRegistry` FAILS FAST at boot, naming every directory the catalogs were read from, when a bound
249
+ `McpToolRegistry` (built by the `McpToolset` constructor) FAILS FAST at boot, naming every directory the catalogs were read from, when a bound
102
250
  contract has no catalog, a catalog's contract is not bound, or a tool name is declared twice — and when
103
251
  a registered `@WpMcpTool` is absent from its contract's catalog: a tool the build never saw is a tool
104
252
  whose schema nobody checked. Tool arguments and structured output are
@@ -127,29 +275,30 @@ The two are kept apart.
127
275
 
128
276
  `WpMcpServerConfig` is built with fluent setters — every name sits next to its own value, so no two
129
277
  settings can be swapped, and a future setting is an additive setter rather than a breaking signature
130
- change. Expect to supply the type arguments explicitly:
278
+ change. WHICH tools are served is the `McpToolset`, not the config:
131
279
 
132
280
  ```ts
133
- const config = new WpMcpServerConfig<MyGrant>()
281
+ const config = new WpMcpServerConfig()
134
282
  .setName('my-server')
135
283
  .setTitle('My Product')
136
284
  .setDescription('Search and manage My Product accounts from any MCP client.')
137
285
  .setWebsiteUrl('https://www.example.com/my-product')
138
286
  .setVersion('1.0.0')
139
287
  .setResource('https://api.example.com/mcp')
140
- .setAccessTokenAuthority(authority)
141
- .setAuthorizationService(router.authorizationService())
142
- .setAuthorizationServers(['https://login.example.com'])
143
- .setRequiredScopes(['tools'])
144
- .setMaxAccountValidationAgeSeconds(15 * 60);
288
+ .setAuth([
289
+ mcpOAuth(authority, ['https://login.example.com'], ['tools'],
290
+ McpAccessTokenFormat.opaquePrefix('wpmcp_'), 15 * 60),
291
+ ]);
145
292
 
146
293
  // The tools/call error translator is a PROCESS-GLOBAL, not a member of this config:
147
294
  McpRegistry.setErrorTranslator(new MyMcpErrorTranslator());
148
295
  ```
149
296
 
150
297
  Each setter validates its own value immediately and names itself in the failure: `setResource(...)`
151
- and every entry of `setAuthorizationServers(...)` must be an absolute URL, because they are compared
152
- against a token's audience and issuer. `setMaxAccountValidationAgeSeconds` defaults to one hour and is capped there.
298
+ and every `mcpOAuth(...)` authorization server must be an absolute URL, because they are compared
299
+ against a token's audience and issuer. `mcpOAuth(...)`'s `maxAccountValidationAgeSeconds` defaults to one
300
+ hour and is capped there. `setCredentialVerifiers(router.credentialVerifiers())` is required once
301
+ `setAuth([...])` holds any `@WpAuth` mode.
153
302
 
154
303
  `setTitle`, `setDescription` and `setWebsiteUrl` are REQUIRED and are sent on `initialize`'s
155
304
  `serverInfo` (and on every modern-era result's `io.modelcontextprotocol/serverInfo` meta) as
@@ -167,26 +316,28 @@ server may bind contracts from several api libraries. `websiteUrl` is a page a h
167
316
  homepage or docs site), so it is never derived from `setResource` — that is the MCP endpoint, not a
168
317
  website. There are no defaults; a missing one fails `bind(...)` naming the setter, like `setName`.
169
318
 
170
- `setIcons(...)` and `setToolProfiles(...)` are the only optional non-ceiling settings (the error
319
+ `setIcons(...)` is the only optional setting (the error
171
320
  translator is process-global, on `McpRegistry`); everything else is required and
172
321
  `WpMcpServer.bind(...)` fails at startup listing **every** missing setter
173
- (`WpMcpServerConfig is missing setResource(...)`). That is deliberate: a misconfigured audience or
174
- issuer otherwise surfaces as a first-request `401 + WWW-Authenticate`, which is the OAuth discovery
175
- signal, so a client answers it by re-authenticating and failing again — forever.
322
+ (`WpMcpServerConfig is missing setResource(...)`). That is deliberate: with `mcpOAuth(...)`, a
323
+ misconfigured audience or issuer otherwise surfaces as a first-request `401 + WWW-Authenticate`, which is
324
+ the OAuth discovery signal, so a client answers it by re-authenticating and failing again — forever.
176
325
 
177
- Applications supply the endpoint path, explicit bindings, and deployment topology. There is no
326
+ Applications supply the toolset, the endpoint path, and deployment topology. There is no
178
327
  framework-owned `/mcp` path:
179
328
 
180
329
  ```ts
181
- const mcp = new WpMcpServer(config);
182
- mcp.bind(expressApp, new McpBindOptions(
183
- connectorConfig.endpointPath,
330
+ const toolset = new McpToolset(
184
331
  [
185
332
  McpApiBinding.local(OrdersApi, apiFactory),
186
333
  McpApiBinding.remote(RemoteOrdersApi, () => remoteOrdersClient),
187
334
  ],
188
- McpDeployment.singleProcess(),
189
- ));
335
+ catalogs,
336
+ apiFactory.authorizationService(),
337
+ [Mcp.DEFAULT],
338
+ );
339
+ const mcp = new WpMcpServer(config, toolset);
340
+ mcp.bind(expressApp, new McpBindOptions(connectorConfig.endpointPath, McpDeployment.singleProcess()));
190
341
  ```
191
342
 
192
343
  `bind(...)` registers POST at that path plus a 405 (`Allow: POST`) for every other method there —
@@ -210,8 +361,8 @@ happened when this bridge was modern-only and every shipping client (all still o
210
361
  generation) got `-32022 Unsupported protocol version` at `initialize`. Only a revision NO era knows is
211
362
  refused, which is what the MCP lifecycle spec asks of a server.
212
363
 
213
- The external bearer is verified identically on both legs, at the `bind(...)` HTTP boundary, before the
214
- SDK is involved — the era is the SDK's decision, the authentication is not.
364
+ The caller's credential is verified identically on both legs, at the `bind(...)` HTTP boundary, before
365
+ the SDK is involved — the era is the SDK's decision, the authentication is not.
215
366
 
216
367
  The official MCP v2 server and Node adapter own JSON-RPC validation for both eras, JSON versus
217
368
  request-scoped SSE responses, backpressure, cancellation, keepalives, and subscriptions. Webpieces
@@ -242,11 +393,12 @@ TTL, registry metadata contains a surface-derived revision, `toolsChanged()` pub
242
393
  level-triggered invalidation, and `close()` drains active response streams. A reconnecting client must
243
394
  re-listen and refresh its authoritative lists; notifications are not a durable or replayable event log.
244
395
 
245
- The authority verifies issuer, signature/token state, expiry, scopes, the exact resource URI, and
246
- current account state. Neither served era keeps a session here, so the bearer is verified once per POST
247
- at the `bind(...)` HTTP boundary, before the SDK is involved; tool handlers never re-verify it. A
248
- rejected token must be thrown as `ApiUnauthorizedError` (answered 401 + `WWW-Authenticate`); any
249
- other throw from the authority is an implementation failure (500). MCP access tokens are capped at 30 days.
396
+ With `mcpOAuth(...)`, the authority verifies issuer, signature/token state, expiry, scopes, the exact
397
+ resource URI, and current account state. Neither served era keeps a session here, so the credential is
398
+ verified once per POST at the `bind(...)` HTTP boundary, before the SDK is involved; tool handlers never
399
+ re-verify it. A rejected token must be thrown as `ApiUnauthorizedError` (answered 401 +
400
+ `WWW-Authenticate`); any other throw from the authority is an implementation failure (500). MCP access
401
+ tokens are capped at 30 days.
250
402
 
251
403
  ## Principal, policy and request context
252
404
 
@@ -398,8 +550,10 @@ the caller service's `ApiImplementationError`; other 5xx remains generic `ApiDep
398
550
  ### Why there is no `fromWire` on `McpErrorTranslator`
399
551
 
400
552
  The HTTP and IPC translators have two halves because webpieces sits on both ends of those wires.
401
- **webpieces is never the MCP client** — Claude is — so there is no return path for webpieces to
402
- translate. The pair is one-directional by nature, not by omission.
553
+ An MCP tool result is read by a MODEL, never decoded back into an exception — even through
554
+ `WpMcpInMemory` / `WpMcpRemote`, which are the SDK's own `Client` handing the agent the `CallToolResult`
555
+ as-is — so there is no return path for webpieces to translate. The pair is one-directional by nature,
556
+ not by omission.
403
557
 
404
558
  The verifier's `accountValidatedAtEpochSeconds` must represent an authoritative enabled/revoked and
405
559
  role/scope read. The bridge enforces a maximum one-hour decision age; per-request reads are preferred so
@@ -465,22 +619,25 @@ Identifiers match `[a-z][a-z0-9-]{0,63}` (1–64 lowercase ASCII letters, digits
465
619
  with a letter). `Mcp.DEFAULT` reserves `default` for the base group. Empty lists, duplicates, unknown
466
620
  option keys, whitespace, paths, and invalid identifiers are rejected. Lists are snapshotted and sorted.
467
621
 
468
- Configure one independent `WpMcpServer` per mount with the same bindings, complete catalogs, and
469
- receiving router's `AuthorizationService`. Start from the required fluent configuration shown above:
622
+ Configure one independent `WpMcpServer` per mount, each over its own `McpToolset` with the same
623
+ bindings, complete catalogs, and receiving router's `AuthorizationService` — the profiles are the
624
+ toolset's REQUIRED fourth argument:
470
625
 
471
626
  ```ts
472
- baseConfig.setResource('https://api.example.com/mcp').setToolProfiles([Mcp.DEFAULT]);
473
- adminConfig.setResource('https://api.example.com/mcp/admin').setToolProfiles(['admin']);
474
- bothConfig.setResource('https://api.example.com/mcp/both').setToolProfiles([Mcp.DEFAULT, 'admin']);
627
+ const toolsetFor = (profiles: McpToolProfiles): McpToolset =>
628
+ new McpToolset(bindings, catalogs, router.authorizationService(), profiles);
629
+ new WpMcpServer(baseConfig.setResource('https://api.example.com/mcp'), toolsetFor([Mcp.DEFAULT]));
630
+ new WpMcpServer(adminConfig.setResource('https://api.example.com/mcp/admin'), toolsetFor(['admin']));
631
+ new WpMcpServer(bothConfig.setResource('https://api.example.com/mcp/both'), toolsetFor([Mcp.DEFAULT, 'admin']));
475
632
  ```
476
633
 
477
634
  Bind each instance with `McpBindOptions.endpointPath` equal to its resource path: `/mcp`,
478
- `/mcp/admin`, or `/mcp/both`. Omitting `setToolProfiles` selects `[Mcp.DEFAULT]`.
635
+ `/mcp/admin`, or `/mcp/both`.
479
636
  A combined selection is the union, with shared tools listed once in stable name order. Further groups
480
637
  such as `course-authoring` work identically. No caller-controlled profile route or hint exists.
481
- Configure instances before binding; the selected groups are fixed when bound.
638
+ The toolset snapshots its groups at construction; they are fixed for its lifetime.
482
639
 
483
- Publish each instance's `protectedResourceMetadata()` at its configuration's `resourceMetadataUrl`,
640
+ With `mcpOAuth(...)`, publish each instance's `protectedResourceMetadata()` at its configuration's `resourceMetadataUrl`,
484
641
  e.g. `/.well-known/oauth-protected-resource/mcp/admin`. Token authorities must verify the fixed exact
485
642
  resource, trusted issuer, expiry, scopes, and fresh account state. A `/mcp` token is rejected on
486
643
  `/mcp/admin`. Never derive audiences from Host headers. OAuth consent, grants, and connector setup
@@ -498,7 +655,7 @@ Configure optional standard MCP implementation icons through the fluent config:
498
655
  ```ts
499
656
  import { McpIcon, WpMcpServerConfig } from '@webpieces/mcp-server';
500
657
 
501
- const config = new WpMcpServerConfig<MyGrant>()
658
+ const config = new WpMcpServerConfig()
502
659
  .setName('my-product')
503
660
  .setTitle('My Product')
504
661
  .setDescription('Search and manage My Product accounts from any MCP client.')
@@ -513,7 +670,7 @@ const config = new WpMcpServerConfig<MyGrant>()
513
670
  'light',
514
671
  ),
515
672
  ]);
516
- // Supply the remaining required authority, authorization and scope setters as usual.
673
+ // Supply the required setAuth([...]) as usual.
517
674
  ```
518
675
 
519
676
  `setIcons` accepts standard SDK `Icon` descriptors (`src`, optional `mimeType`, `sizes`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/mcp-server",
3
- "version": "0.4.990",
3
+ "version": "0.4.992",
4
4
  "description": "Secure MCP tool generation from annotated Webpieces API contracts",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -22,17 +22,18 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
+ "@modelcontextprotocol/client": "2.0.0",
25
26
  "@modelcontextprotocol/node": "2.0.0",
26
27
  "@modelcontextprotocol/server": "2.0.0",
27
- "@webpieces/core-context": "0.4.990",
28
- "@webpieces/core-util": "0.4.990",
29
- "@webpieces/http-routing": "0.4.990",
30
- "@webpieces/http-server": "0.4.990",
28
+ "@webpieces/core-context": "0.4.992",
29
+ "@webpieces/core-util": "0.4.992",
30
+ "@webpieces/http-routing": "0.4.992",
31
+ "@webpieces/http-server": "0.4.992",
31
32
  "express": "5.1.0",
32
33
  "reflect-metadata": "0.2.2",
33
34
  "tslib": "2.8.1"
34
35
  },
35
36
  "devDependencies": {
36
- "@webpieces/http-client-node": "0.4.990"
37
+ "@webpieces/http-client-node": "0.4.992"
37
38
  }
38
39
  }
@@ -1,10 +1,13 @@
1
1
  import { DtoValue } from '@webpieces/core-util';
2
2
  import { AuthorizationService } from '@webpieces/http-routing';
3
3
  import { RegisteredMcpTool } from './McpToolRegistry';
4
- import { VerifiedMcpCredential } from './McpAuth';
5
4
  import { McpInvocationContext } from '@webpieces/core-util';
6
- /** Authorize, validate, then issue a local credential and invoke the ordinary filters. No scope or catch. */
5
+ /**
6
+ * Authorize, validate, then issue a local credential and invoke the ordinary filters. No scope or
7
+ * catch. The caller is whoever is trusted in the CURRENT RequestContext — the transport established
8
+ * it — so the dispatcher takes no credential of its own.
9
+ */
7
10
  export declare class McpApiDispatcher {
8
11
  private readonly schemas;
9
- call(tool: RegisteredMcpTool, requestDto: DtoValue, credential: VerifiedMcpCredential, invocation: McpInvocationContext, policy: AuthorizationService): Promise<DtoValue>;
12
+ call(tool: RegisteredMcpTool, requestDto: DtoValue, invocation: McpInvocationContext, policy: AuthorizationService): Promise<DtoValue>;
10
13
  }
@@ -6,10 +6,14 @@ const core_context_1 = require("@webpieces/core-context");
6
6
  const http_routing_1 = require("@webpieces/http-routing");
7
7
  const McpToolPolicy_1 = require("./McpToolPolicy");
8
8
  const core_util_2 = require("@webpieces/core-util");
9
- /** Authorize, validate, then issue a local credential and invoke the ordinary filters. No scope or catch. */
9
+ /**
10
+ * Authorize, validate, then issue a local credential and invoke the ordinary filters. No scope or
11
+ * catch. The caller is whoever is trusted in the CURRENT RequestContext — the transport established
12
+ * it — so the dispatcher takes no credential of its own.
13
+ */
10
14
  class McpApiDispatcher {
11
15
  schemas = new core_util_1.ApiJsonSchemaValidator();
12
- async call(tool, requestDto, credential, invocation, policy) {
16
+ async call(tool, requestDto, invocation, policy) {
13
17
  await new McpToolPolicy_1.McpToolPolicy(policy).require(tool.authorization, tool.name);
14
18
  const inputFailure = this.schemas.validate(tool.inputSchema, requestDto);
15
19
  if (inputFailure) {
@@ -1 +1 @@
1
- {"version":3,"file":"McpApiDispatcher.js","sourceRoot":"","sources":["../../../../../packages/http/mcp-server/src/McpApiDispatcher.ts"],"names":[],"mappings":";;;AAAA,oDAA4F;AAC5F,0DAAyD;AACzD,0DAAyF;AACzF,mDAAgD;AAGhD,oDAAoF;AAEpF,6GAA6G;AAC7G,MAAa,gBAAgB;IACR,OAAO,GAAG,IAAI,kCAAsB,EAAE,CAAC;IAExD,KAAK,CAAC,IAAI,CACN,IAAuB,EACvB,UAAoB,EACpB,UAAiC,EACjC,UAAgC,EAChC,MAA4B;QAE5B,MAAM,IAAI,6BAAa,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,aAAa,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QACvE,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC;QACzE,IAAI,YAAY,EAAE,CAAC;YACf,MAAM,IAAI,8BAAkB,CACxB,qBAAqB,IAAI,CAAC,IAAI,8CAA8C,YAAY,CAAC,OAAO,EAAE,EAClG,YAAY,CAAC,KAAK,EAClB,YAAY,CAAC,OAAO,CACvB,CAAC;QACN,CAAC;QACD,MAAM,cAAc,GAChB,IAAI,CAAC,OAAO,CAAC,QAAQ,KAAK,OAAO;YAC7B,CAAC,CAAC,IAAI,uCAAwB,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,UAAU,CAAC;YAC9D,CAAC,CAAC,SAAS,CAAC;QACpB,6BAAc,CAAC,UAAU,CAAC,kCAAsB,EAAE,UAAU,CAAC,CAAC;QAC9D,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,UAAU,EAAE,cAAc,CAAC,CAAC;IAC5E,CAAC;CACJ;AA1BD,4CA0BC","sourcesContent":["import { ApiBadRequestError, ApiJsonSchemaValidator, DtoValue } from '@webpieces/core-util';\nimport { RequestContext } from '@webpieces/core-context';\nimport { InvocationAuthentication, AuthorizationService } from '@webpieces/http-routing';\nimport { McpToolPolicy } from './McpToolPolicy';\nimport { RegisteredMcpTool } from './McpToolRegistry';\nimport { VerifiedMcpCredential } from './McpAuth';\nimport { MCP_INVOCATION_CONTEXT, McpInvocationContext } from '@webpieces/core-util';\n\n/** Authorize, validate, then issue a local credential and invoke the ordinary filters. No scope or catch. */\nexport class McpApiDispatcher {\n private readonly schemas = new ApiJsonSchemaValidator();\n\n async call(\n tool: RegisteredMcpTool,\n requestDto: DtoValue,\n credential: VerifiedMcpCredential,\n invocation: McpInvocationContext,\n policy: AuthorizationService,\n ): Promise<DtoValue> {\n await new McpToolPolicy(policy).require(tool.authorization, tool.name);\n const inputFailure = this.schemas.validate(tool.inputSchema, requestDto);\n if (inputFailure) {\n throw new ApiBadRequestError(\n `MCP arguments for ${tool.name} did not match its published input schema: ${inputFailure.message}`,\n inputFailure.field,\n inputFailure.message,\n );\n }\n const authentication =\n tool.binding.topology === 'local'\n ? new InvocationAuthentication(tool.apiClass, tool.methodName)\n : undefined;\n RequestContext.putTrusted(MCP_INVOCATION_CONTEXT, invocation);\n return tool.binding.invoke(tool.methodName, requestDto, authentication);\n }\n}\n"]}
1
+ {"version":3,"file":"McpApiDispatcher.js","sourceRoot":"","sources":["../../../../../packages/http/mcp-server/src/McpApiDispatcher.ts"],"names":[],"mappings":";;;AAAA,oDAA4F;AAC5F,0DAAyD;AACzD,0DAAyF;AACzF,mDAAgD;AAEhD,oDAAoF;AAEpF;;;;GAIG;AACH,MAAa,gBAAgB;IACR,OAAO,GAAG,IAAI,kCAAsB,EAAE,CAAC;IAExD,KAAK,CAAC,IAAI,CACN,IAAuB,EACvB,UAAoB,EACpB,UAAgC,EAChC,MAA4B;QAE5B,MAAM,IAAI,6BAAa,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,aAAa,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QACvE,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC;QACzE,IAAI,YAAY,EAAE,CAAC;YACf,MAAM,IAAI,8BAAkB,CACxB,qBAAqB,IAAI,CAAC,IAAI,8CAA8C,YAAY,CAAC,OAAO,EAAE,EAClG,YAAY,CAAC,KAAK,EAClB,YAAY,CAAC,OAAO,CACvB,CAAC;QACN,CAAC;QACD,MAAM,cAAc,GAChB,IAAI,CAAC,OAAO,CAAC,QAAQ,KAAK,OAAO;YAC7B,CAAC,CAAC,IAAI,uCAAwB,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,UAAU,CAAC;YAC9D,CAAC,CAAC,SAAS,CAAC;QACpB,6BAAc,CAAC,UAAU,CAAC,kCAAsB,EAAE,UAAU,CAAC,CAAC;QAC9D,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,UAAU,EAAE,cAAc,CAAC,CAAC;IAC5E,CAAC;CACJ;AAzBD,4CAyBC","sourcesContent":["import { ApiBadRequestError, ApiJsonSchemaValidator, DtoValue } from '@webpieces/core-util';\nimport { RequestContext } from '@webpieces/core-context';\nimport { InvocationAuthentication, AuthorizationService } from '@webpieces/http-routing';\nimport { McpToolPolicy } from './McpToolPolicy';\nimport { RegisteredMcpTool } from './McpToolRegistry';\nimport { MCP_INVOCATION_CONTEXT, McpInvocationContext } from '@webpieces/core-util';\n\n/**\n * Authorize, validate, then issue a local credential and invoke the ordinary filters. No scope or\n * catch. The caller is whoever is trusted in the CURRENT RequestContext — the transport established\n * it — so the dispatcher takes no credential of its own.\n */\nexport class McpApiDispatcher {\n private readonly schemas = new ApiJsonSchemaValidator();\n\n async call(\n tool: RegisteredMcpTool,\n requestDto: DtoValue,\n invocation: McpInvocationContext,\n policy: AuthorizationService,\n ): Promise<DtoValue> {\n await new McpToolPolicy(policy).require(tool.authorization, tool.name);\n const inputFailure = this.schemas.validate(tool.inputSchema, requestDto);\n if (inputFailure) {\n throw new ApiBadRequestError(\n `MCP arguments for ${tool.name} did not match its published input schema: ${inputFailure.message}`,\n inputFailure.field,\n inputFailure.message,\n );\n }\n const authentication =\n tool.binding.topology === 'local'\n ? new InvocationAuthentication(tool.apiClass, tool.methodName)\n : undefined;\n RequestContext.putTrusted(MCP_INVOCATION_CONTEXT, invocation);\n return tool.binding.invoke(tool.methodName, requestDto, authentication);\n }\n}\n"]}