@reactionary/mcp 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,462 @@
1
- # Temporary Holding Version
1
+ # @reactionary/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@reactionary/mcp` exposes an instantiated Reactionary client surface as a Model Context Protocol (MCP) HTTP service.
4
+
5
+ The package is intentionally thin:
6
+
7
+ - Reactionary remains the commerce abstraction layer.
8
+ - Host applications own routing, deployment, authentication, policy, and persistence choices.
9
+ - MCP tools are generated from existing Reactionary capability metadata instead of being hand-written one by one.
10
+ - The same runtime can be mounted from frameworks such as Next.js, Express, or plain Node HTTP.
11
+
12
+ ## Design goals
13
+
14
+ The adapter exists to make Reactionary usable by agents without turning Reactionary into a hosted platform or router.
15
+
16
+ The important boundary is:
17
+
18
+ - **Reactionary packages** provide typed commerce capabilities.
19
+ - **`@reactionary/mcp`** converts those capabilities into MCP tools, resources, and prompts.
20
+ - **The host app** decides where `/mcp` lives, how users authenticate, how sessions are stored, and which capabilities are enabled.
21
+
22
+ This package therefore exports `ReactionaryMCPServer` instead of adding a built-in `/mcp` route to Reactionary itself.
23
+
24
+ ## Core API
25
+
26
+ ```ts
27
+ import { ReactionaryMCPServer } from '@reactionary/mcp';
28
+ import { createReactionaryClient } from './my-reactionary-client';
29
+
30
+ const mcp = new ReactionaryMCPServer((requestContext) =>
31
+ createReactionaryClient({
32
+ context: requestContext,
33
+ }),
34
+ );
35
+
36
+ export async function POST(request: Request) {
37
+ return mcp.fetch(request);
38
+ }
39
+ ```
40
+
41
+ `ReactionaryMCPServer` takes a **client factory**, not a singleton client:
42
+
43
+ ```ts
44
+ type ReactionaryMCPClientFactory = (
45
+ requestContext: RequestContext,
46
+ ) => ReactionaryMCPClient;
47
+ ```
48
+
49
+ The factory is called for each MCP serving unit with a fresh `RequestContext`. This matters because Reactionary capabilities hold a mutable request context. Identity, cart, checkout, personalization, and provider-specific session data can change during tool calls.
50
+
51
+ Passing a singleton client would let one MCP user/session inherit another user's identity or cart state, so the public runtime is designed around per-request/per-session client construction.
52
+
53
+ ## MCP server wrapper
54
+
55
+ Internally, the package uses:
56
+
57
+ - `@modelcontextprotocol/server`
58
+ - `createMcpHandler(...)`
59
+ - `McpServer`
60
+ - `fromJsonSchema(...)`
61
+ - `@modelcontextprotocol/node`
62
+ - `toNodeHandler(...)`
63
+
64
+ `ReactionaryMCPServer` exposes both Web-standard and Node-style integration:
65
+
66
+ ```ts
67
+ const mcp = new ReactionaryMCPServer(createClient);
68
+
69
+ // Web-standard runtimes: Next.js route handlers, Fetch API servers, Workers-like runtimes
70
+ await mcp.fetch(request);
71
+
72
+ // Node HTTP / Express-style integration
73
+ const nodeHandler = mcp.toNodeHandler();
74
+ ```
75
+
76
+ The core package does not depend on Express.
77
+
78
+ ## Tool discovery
79
+
80
+ MCP tools are discovered from Reactionary capability methods decorated with `@Reactionary(...)`.
81
+
82
+ The metadata comes from core capability runtime discovery:
83
+
84
+ - capability resource name
85
+ - method name
86
+ - input Zod schema
87
+ - output Zod schema
88
+ - title
89
+ - description
90
+
91
+ Default tool names are:
92
+
93
+ ```txt
94
+ <capabilityName>.<methodName>
95
+ ```
96
+
97
+ Examples:
98
+
99
+ ```txt
100
+ product.getBySKU
101
+ productSearch.queryByTerm
102
+ price.getListPrice
103
+ inventory.getBySKU
104
+ cart.addItem
105
+ checkout.startCheckout
106
+ ```
107
+
108
+ Tool names can be customized with the `toolName` option:
109
+
110
+ ```ts
111
+ const mcp = new ReactionaryMCPServer(createClient, {
112
+ toolName: (capabilityName, methodName) =>
113
+ `reactionary.${capabilityName}.${methodName}`,
114
+ });
115
+ ```
116
+
117
+ ## Schema conversion and defaults
118
+
119
+ Reactionary schemas are Zod schemas. MCP expects JSON-Schema-like tool contracts. `@reactionary/mcp` converts schemas with:
120
+
121
+ ```ts
122
+ z.toJSONSchema(schema, { io: 'input' })
123
+ fromJsonSchema(...)
124
+ ```
125
+
126
+ There are two important details:
127
+
128
+ 1. **Descriptions are preserved**
129
+
130
+ Zod `.meta({ description })` and compatible descriptions become MCP field descriptions through Zod JSON Schema conversion.
131
+
132
+ 2. **Defaults are handled defensively**
133
+
134
+ Some legacy schemas used default factories such as:
135
+
136
+ ```ts
137
+ SomeSchema.default(() => SomeSchema.parse({}))
138
+ ```
139
+
140
+ That is unsafe when `SomeSchema.parse({})` is invalid. Zod JSON Schema generation can evaluate those default factories, which can crash tool discovery.
141
+
142
+ The MCP adapter now prepares schemas before conversion:
143
+
144
+ - safe defaults are preserved and shown to MCP clients
145
+ - unsafe defaults are omitted from the MCP schema
146
+ - fields with unsafe defaults remain optional to preserve the "can be omitted" intent
147
+ - descriptions/metadata are copied when schemas are rebuilt for JSON Schema generation
148
+
149
+ ### Monetary and cost default note
150
+
151
+ Core monetary amounts remain strict: a raw money object must include both `value` and `currency`. Empty provider data such as `{}` should fail output validation rather than silently becoming a real price.
152
+
153
+ Some aggregate cart/checkout cost-breakdown fields still use a temporary placeholder default:
154
+
155
+ ```json
156
+ { "value": 0, "currency": "EUR" }
157
+ ```
158
+
159
+ That fallback is intentionally placed on the parent cost-breakdown fields, not on `MonetaryAmountSchema` itself. It should eventually be replaced by configured/request-context currency handling or explicit "unknown monetary amount" modeling.
160
+
161
+ ## Tool result envelope
162
+
163
+ Successful MCP tool calls return structured content in a consistent envelope:
164
+
165
+ ```json
166
+ {
167
+ "value": {}
168
+ }
169
+ ```
170
+
171
+ Void-returning capabilities use:
172
+
173
+ ```json
174
+ {
175
+ "value": null
176
+ }
177
+ ```
178
+
179
+ This keeps successful `undefined`, scalar, array, and object results protocol-valid and aligned with the advertised MCP output schema.
180
+
181
+ ## MCP session handling
182
+
183
+ Each MCP session must preserve the Reactionary `RequestContext.session` across requests.
184
+
185
+ `ReactionaryMCPServer` handles this by:
186
+
187
+ 1. Reading the `mcp-session-id` request header.
188
+ 2. Creating a new session id when the request has none.
189
+ 3. Creating a fresh `RequestContext`.
190
+ 4. Restoring the cached `.session` into that context when a previous session exists.
191
+ 5. Building a fresh Reactionary client with that context.
192
+ 6. Persisting `requestContext.session` back to cache after each tool call.
193
+ 7. Returning `mcp-session-id` in the response headers.
194
+
195
+ Only `.session` is persisted. Other request context fields, such as locale, currency, store, tax jurisdiction, correlation id, IP address, and user agent, are expected to be supplied by the host per request.
196
+
197
+ ### Session cache
198
+
199
+ By default, the package uses Reactionary `MemoryCache` for MCP session state:
200
+
201
+ ```ts
202
+ const mcp = new ReactionaryMCPServer(createClient);
203
+ ```
204
+
205
+ For production or multi-instance deployments, provide any Reactionary `Cache` implementation:
206
+
207
+ ```ts
208
+ const mcp = new ReactionaryMCPServer(createClient, {
209
+ sessionCache: redisBackedReactionaryCache,
210
+ sessionTtlSeconds: 60 * 60,
211
+ });
212
+ ```
213
+
214
+ The cache is used only for the MCP session snapshot. Capability-level caching is still controlled by the Reactionary clients/capabilities created by your factory.
215
+
216
+ ### Why not reuse one client?
217
+
218
+ Reactionary capabilities receive a mutable `RequestContext`. Several capabilities read or write session data:
219
+
220
+ - identity login/logout
221
+ - active user identity
222
+ - personalization profile
223
+ - cart-related provider state
224
+ - checkout state
225
+ - provider-specific session keys
226
+
227
+ If the HTTP handler reused a single client instance, all MCP sessions would share that context. That would allow one connected client to inherit another client's identity, token, active cart, or personalization state.
228
+
229
+ The adapter therefore creates a fresh client/context per request and restores only the session snapshot associated with the MCP session id.
230
+
231
+ ## Resources and prompts
232
+
233
+ The package exposes a shopping-agent guide as both an MCP resource and an MCP prompt.
234
+
235
+ Resource URI:
236
+
237
+ ```txt
238
+ reactionary://mcp/guide/shopping-agent
239
+ ```
240
+
241
+ Prompt name:
242
+
243
+ ```txt
244
+ reactionary-shopping-agent-guide
245
+ ```
246
+
247
+ The guide explains how agents should combine Reactionary tools for product discovery, product details, price/inventory checks, cart operations, checkout, pagination, B2B context, and error handling.
248
+
249
+ The resource uses a Reactionary-owned URI scheme. Some clients, including VS Code, may show MCP resources internally as `mcp-resource://...`; that is a client-side virtual URI and not the URI the server should advertise.
250
+
251
+ See [`docs/shopping-agent-guide.md`](./docs/shopping-agent-guide.md).
252
+
253
+ ## CLI
254
+
255
+ The package includes a small CLI for local demos and MCP Inspector testing.
256
+
257
+ ```bash
258
+ pnpm --dir packages/mcp start
259
+ ```
260
+
261
+ Defaults:
262
+
263
+ ```txt
264
+ MCP_HOST=127.0.0.1
265
+ MCP_PORT=3000
266
+ MCP_PATH=/mcp
267
+ ```
268
+
269
+ The CLI:
270
+
271
+ 1. Loads `.env` from the project root.
272
+ 2. Reads enabled provider systems from `ENABLED_<SYSTEM>=true`.
273
+ 3. Creates a `ReactionaryMCPServer`.
274
+ 4. Serves MCP over streamable HTTP.
275
+ 5. Creates a fresh Reactionary client/context per MCP request/session.
276
+
277
+ Supported provider flags:
278
+
279
+ ```txt
280
+ ENABLED_FAKE=true
281
+ ENABLED_MEDUSA=true
282
+ ENABLED_MAGENTO=true
283
+ ENABLED_COMMERCETOOLS=true
284
+ ENABLED_ALGOLIA=true
285
+ ENABLED_MEILISEARCH=true
286
+ ENABLED_UNOMI=true
287
+ ```
288
+
289
+ Example:
290
+
291
+ ```bash
292
+ ENABLED_FAKE=true pnpm --dir packages/mcp start
293
+ ```
294
+
295
+ ### Start without Inspector
296
+
297
+ Use this when you want to run the MCP HTTP server directly and connect any MCP-capable client yourself.
298
+
299
+ Fake provider demo:
300
+
301
+ ```bash
302
+ ENABLED_FAKE=true pnpm --dir packages/mcp start
303
+ ```
304
+
305
+ Custom host, port, and path:
306
+
307
+ ```bash
308
+ ENABLED_FAKE=true pnpm --dir packages/mcp start -- --host 127.0.0.1 --port 3333 --path /mcp
309
+ ```
310
+
311
+ Equivalent environment-variable form:
312
+
313
+ ```bash
314
+ ENABLED_FAKE=true MCP_HOST=127.0.0.1 MCP_PORT=3333 MCP_PATH=/mcp pnpm --dir packages/mcp start
315
+ ```
316
+
317
+ The server will listen at:
318
+
319
+ ```txt
320
+ http://127.0.0.1:3333/mcp
321
+ ```
322
+
323
+ Medusa-backed example:
324
+
325
+ ```bash
326
+ ENABLED_MEDUSA=true \
327
+ MEDUSA_BASE_URL=http://localhost:9000 \
328
+ MEDUSA_PUBLISHABLE_KEY=pk_test_... \
329
+ MEDUSA_ADMIN_KEY=... \
330
+ pnpm --dir packages/mcp start
331
+ ```
332
+
333
+ You can also put these variables in the project root `.env`; the CLI loads that file automatically.
334
+
335
+ ### Inspector
336
+
337
+ For local development:
338
+
339
+ ```bash
340
+ pnpm --dir packages/mcp inspect
341
+ ```
342
+
343
+ This starts the local Reactionary MCP server and opens MCP Inspector against the configured server URL.
344
+
345
+ Fake provider with Inspector:
346
+
347
+ ```bash
348
+ ENABLED_FAKE=true pnpm --dir packages/mcp inspect
349
+ ```
350
+
351
+ Inspector against a custom local endpoint:
352
+
353
+ ```bash
354
+ ENABLED_FAKE=true MCP_HOST=127.0.0.1 MCP_PORT=3333 MCP_PATH=/mcp pnpm --dir packages/mcp inspect
355
+ ```
356
+
357
+ The inspector launcher starts the Reactionary MCP server first, then launches MCP Inspector using HTTP transport pointed at:
358
+
359
+ ```txt
360
+ http://${MCP_HOST}:${MCP_PORT}${MCP_PATH}
361
+ ```
362
+
363
+ For the defaults, that is:
364
+
365
+ ```txt
366
+ http://127.0.0.1:3000/mcp
367
+ ```
368
+
369
+ ## Environment-backed client builder
370
+
371
+ `createReactionaryClientFromEnv(...)` is a convenience helper for demos and CLI usage. It mirrors the provider setup logic from the example Node package and enables capabilities based on environment variables.
372
+
373
+ Embedded applications do not have to use this helper. In production, prefer a host-owned client factory:
374
+
375
+ ```ts
376
+ const mcp = new ReactionaryMCPServer((requestContext) => {
377
+ return new ClientBuilder(requestContext)
378
+ .withCache(myCapabilityCache)
379
+ .withCapability(...)
380
+ .build();
381
+ }, {
382
+ sessionCache: mySessionCache,
383
+ });
384
+ ```
385
+
386
+ ## Integration examples
387
+
388
+ ### Next.js route handler
389
+
390
+ ```ts
391
+ // app/api/mcp/route.ts
392
+ import { ReactionaryMCPServer } from '@reactionary/mcp';
393
+ import { buildReactionaryClient } from '@/lib/reactionary';
394
+
395
+ const mcp = new ReactionaryMCPServer((requestContext) =>
396
+ buildReactionaryClient({ requestContext }),
397
+ );
398
+
399
+ export async function POST(request: Request) {
400
+ return mcp.fetch(request);
401
+ }
402
+
403
+ export async function GET(request: Request) {
404
+ return mcp.fetch(request);
405
+ }
406
+ ```
407
+
408
+ ### Plain Node HTTP
409
+
410
+ ```ts
411
+ import { createServer } from 'node:http';
412
+ import { ReactionaryMCPServer } from '@reactionary/mcp';
413
+
414
+ const mcp = new ReactionaryMCPServer(createClient);
415
+ const handler = mcp.toNodeHandler();
416
+
417
+ createServer(async (request, response) => {
418
+ await handler(request, response);
419
+ }).listen(3000);
420
+ ```
421
+
422
+ ## Guardrails and responsibilities
423
+
424
+ This package provides the MCP transport and generated tool surface. It does not replace application-level commerce policy.
425
+
426
+ Recommended host responsibilities:
427
+
428
+ - authenticate users before exposing sensitive/stateful capabilities
429
+ - authorize tool access by user, role, market, channel, or company
430
+ - rate limit agent traffic
431
+ - verify price and inventory before cart/checkout mutations
432
+ - enforce spend limits
433
+ - audit state-changing tool calls
434
+ - avoid exposing admin-only provider credentials to public agent clients
435
+
436
+ For a first demo, prefer a small surface:
437
+
438
+ 1. Product search
439
+ 2. Product detail / variant resolution
440
+ 3. Price and inventory verification when available
441
+ 4. Cart create/add/update
442
+ 5. Checkout handoff later
443
+
444
+ ## Current limitations and follow-ups
445
+
446
+ - MCP session persistence currently stores only `RequestContext.session`.
447
+ - The default MCP session store is in-memory and not suitable for multi-process production deployments.
448
+ - Some core schemas still contain legacy empty-object default factories for identifiers. The MCP schema converter avoids crashing on those, but core modeling should eventually distinguish required identifiers from optional/unknown values more explicitly.
449
+ - Monetary defaults currently use a temporary `0/EUR` fallback.
450
+ - Checkout/payment/ACP Instant Checkout support is intentionally not implemented in this first MCP package slice.
451
+
452
+ ## Validation commands
453
+
454
+ Useful local checks:
455
+
456
+ ```bash
457
+ pnpm exec tsc -p packages/mcp/tsconfig.spec.json --noEmit
458
+ pnpm exec vitest run --config packages/mcp/vitest.config.mts
459
+ pnpm exec nx run mcp:lint
460
+ pnpm exec nx run mcp:build
461
+ pnpm exec nx affected -t lint build test:offline
462
+ ```
package/cli.js ADDED
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from "node:http";
3
+ import { fileURLToPath } from "node:url";
4
+ import { ReactionaryMCPServer } from "./lib/reactionary-mcp-server.js";
5
+ import {
6
+ createReactionaryClientFromEnv,
7
+ getEnabledReactionaryMCPProviderSystems,
8
+ getNoEnabledProviderSystemsMessage
9
+ } from "./lib/env-client-builder.js";
10
+ import { loadProjectRootEnv } from "./lib/load-project-root-env.js";
11
+ async function main() {
12
+ loadProjectRootEnv(import.meta.url);
13
+ const options = parseOptions(process.argv.slice(2), process.env);
14
+ const enabledSystems = getEnabledReactionaryMCPProviderSystems();
15
+ if (enabledSystems.length === 0) {
16
+ throw new Error(getNoEnabledProviderSystemsMessage());
17
+ }
18
+ const mcp = new ReactionaryMCPServer(
19
+ (requestContext) => createReactionaryClientFromEnv({
20
+ contextOverrides: requestContext
21
+ }).client,
22
+ {
23
+ name: process.env["MCP_SERVER_NAME"] || "@reactionary/mcp",
24
+ version: process.env["MCP_SERVER_VERSION"] || "0.0.1",
25
+ handler: {
26
+ onerror: (error) => {
27
+ console.error(error);
28
+ }
29
+ }
30
+ }
31
+ );
32
+ const mcpHandler = mcp.toNodeHandler();
33
+ const server = createServer(async (request, response) => {
34
+ const requestUrl = new URL(
35
+ request.url ?? "/",
36
+ `http://${request.headers.host ?? `${options.host}:${options.port}`}`
37
+ );
38
+ if (requestUrl.pathname !== options.path) {
39
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
40
+ response.end("Not found");
41
+ return;
42
+ }
43
+ try {
44
+ await mcpHandler(request, response);
45
+ } catch (error) {
46
+ console.error(error);
47
+ if (!response.headersSent) {
48
+ response.writeHead(500, {
49
+ "content-type": "text/plain; charset=utf-8"
50
+ });
51
+ }
52
+ response.end("Internal server error");
53
+ }
54
+ });
55
+ server.on("clientError", (error, socket) => {
56
+ console.error(error);
57
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
58
+ });
59
+ await new Promise((resolve) => {
60
+ server.listen(options.port, options.host, resolve);
61
+ });
62
+ console.error(
63
+ `Reactionary MCP server listening on http://${options.host}:${options.port}${options.path}`
64
+ );
65
+ console.error(`Enabled systems: ${enabledSystems.join(", ")}`);
66
+ const close = async () => {
67
+ await mcp.close();
68
+ await new Promise((resolve, reject) => {
69
+ server.close((error) => {
70
+ if (error) {
71
+ reject(error);
72
+ return;
73
+ }
74
+ resolve();
75
+ });
76
+ });
77
+ };
78
+ process.once("SIGINT", () => {
79
+ void close().finally(() => process.exit(0));
80
+ });
81
+ process.once("SIGTERM", () => {
82
+ void close().finally(() => process.exit(0));
83
+ });
84
+ }
85
+ function parseOptions(args, env) {
86
+ const values = /* @__PURE__ */ new Map();
87
+ for (let index = 0; index < args.length; index += 1) {
88
+ const arg = args[index];
89
+ if (!arg.startsWith("--")) {
90
+ throw new Error(`Unknown argument: ${arg}`);
91
+ }
92
+ const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
93
+ const value = inlineValue ?? args[index + 1];
94
+ if (!value || value.startsWith("--")) {
95
+ throw new Error(`Missing value for argument: ${arg}`);
96
+ }
97
+ values.set(rawKey, value);
98
+ if (inlineValue === void 0) {
99
+ index += 1;
100
+ }
101
+ }
102
+ const host = values.get("host") ?? env["MCP_HOST"] ?? "127.0.0.1";
103
+ const path = values.get("path") ?? env["MCP_PATH"] ?? "/mcp";
104
+ const rawPort = values.get("port") ?? env["MCP_PORT"] ?? env["PORT"] ?? "3000";
105
+ if (!/^\d+$/.test(rawPort)) {
106
+ throw new Error(`Invalid port: ${rawPort}`);
107
+ }
108
+ const port = Number(rawPort);
109
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
110
+ throw new Error(`Invalid port: ${rawPort}`);
111
+ }
112
+ if (!path.startsWith("/")) {
113
+ throw new Error(`MCP path must start with "/": ${path}`);
114
+ }
115
+ return { host, port, path };
116
+ }
117
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
118
+ main().catch((error) => {
119
+ console.error(error);
120
+ process.exit(1);
121
+ });
122
+ }
123
+ export {
124
+ parseOptions
125
+ };
package/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./lib/env-client-builder.js";
2
+ export * from "./lib/reactionary-mcp-server.js";
3
+ export * from "./lib/shopping-agent-guide.js";
4
+ export * from "./lib/tool-discovery.js";