@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.
- package/README.md +199 -42
- package/package.json +7 -6
- package/src/McpApiDispatcher.d.ts +6 -3
- package/src/McpApiDispatcher.js +6 -2
- package/src/McpApiDispatcher.js.map +1 -1
- package/src/McpAuth.d.ts +115 -56
- package/src/McpAuth.js +180 -111
- package/src/McpAuth.js.map +1 -1
- package/src/McpAuthorityCompileAssertions.d.ts +12 -2
- package/src/McpAuthorityCompileAssertions.js +32 -4
- package/src/McpAuthorityCompileAssertions.js.map +1 -1
- package/src/McpBindOptions.d.ts +5 -20
- package/src/McpBindOptions.js +5 -15
- package/src/McpBindOptions.js.map +1 -1
- package/src/McpClientSet.d.ts +35 -0
- package/src/McpClientSet.js +86 -0
- package/src/McpClientSet.js.map +1 -0
- package/src/McpDoorAuth.d.ts +117 -0
- package/src/McpDoorAuth.js +455 -0
- package/src/McpDoorAuth.js.map +1 -0
- package/src/McpToolCallRendering.d.ts +5 -4
- package/src/McpToolCallRendering.js.map +1 -1
- package/src/McpToolCatalog.d.ts +1 -1
- package/src/McpToolCatalog.js +1 -1
- package/src/McpToolCatalog.js.map +1 -1
- package/src/McpToolRegistry.js +8 -2
- package/src/McpToolRegistry.js.map +1 -1
- package/src/McpToolset.d.ts +115 -0
- package/src/McpToolset.js +323 -0
- package/src/McpToolset.js.map +1 -0
- package/src/WpMcpErrorTranslator.d.ts +5 -7
- package/src/WpMcpErrorTranslator.js +7 -13
- package/src/WpMcpErrorTranslator.js.map +1 -1
- package/src/WpMcpInMemory.d.ts +30 -0
- package/src/WpMcpInMemory.js +97 -0
- package/src/WpMcpInMemory.js.map +1 -0
- package/src/WpMcpRemote.d.ts +23 -0
- package/src/WpMcpRemote.js +89 -0
- package/src/WpMcpRemote.js.map +1 -0
- package/src/WpMcpServer.d.ts +45 -76
- package/src/WpMcpServer.js +101 -327
- package/src/WpMcpServer.js.map +1 -1
- package/src/__tests__/McpTransportFixtures.d.ts +107 -0
- package/src/__tests__/McpTransportFixtures.js +266 -0
- package/src/__tests__/McpTransportFixtures.js.map +1 -0
- package/src/index.d.ts +9 -2
- package/src/index.js +17 -1
- 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
|
-
##
|
|
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 `
|
|
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
|
-
|
|
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.
|
|
278
|
+
change. WHICH tools are served is the `McpToolset`, not the config:
|
|
131
279
|
|
|
132
280
|
```ts
|
|
133
|
-
const config = new WpMcpServerConfig
|
|
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
|
-
.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
152
|
-
against a token's audience and issuer. `
|
|
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(...)`
|
|
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:
|
|
174
|
-
issuer otherwise surfaces as a first-request `401 + WWW-Authenticate`, which is
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
246
|
-
current account state. Neither served era keeps a session here, so the
|
|
247
|
-
at the `bind(...)` HTTP boundary, before the SDK is involved; tool handlers never
|
|
248
|
-
rejected token must be thrown as `ApiUnauthorizedError` (answered 401 +
|
|
249
|
-
other throw from the authority is an implementation failure (500). MCP access
|
|
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
|
-
|
|
402
|
-
|
|
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
|
|
469
|
-
receiving router's `AuthorizationService
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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`.
|
|
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
|
-
|
|
638
|
+
The toolset snapshots its groups at construction; they are fixed for its lifetime.
|
|
482
639
|
|
|
483
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
28
|
-
"@webpieces/core-util": "0.4.
|
|
29
|
-
"@webpieces/http-routing": "0.4.
|
|
30
|
-
"@webpieces/http-server": "0.4.
|
|
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.
|
|
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
|
-
/**
|
|
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,
|
|
12
|
+
call(tool: RegisteredMcpTool, requestDto: DtoValue, invocation: McpInvocationContext, policy: AuthorizationService): Promise<DtoValue>;
|
|
10
13
|
}
|
package/src/McpApiDispatcher.js
CHANGED
|
@@ -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
|
-
/**
|
|
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,
|
|
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;
|
|
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"]}
|