@forgewisp/mcp 0.5.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Angelo Cavallo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,327 @@
1
+ # `@forgewisp/mcp`
2
+
3
+ [![GitHub stars](https://img.shields.io/github/stars/forgewisp/forgewisp-core?style=social)](https://github.com/forgewisp/forgewisp-core)
4
+ [![GitHub repo](https://img.shields.io/badge/repo-forgewisp%2Fforgewisp--core-blue)](https://github.com/forgewisp/forgewisp-core)
5
+
6
+ > ⭐ **Found Forgewisp useful? [Star the repo](https://github.com/forgewisp/forgewisp-core) on GitHub.**
7
+
8
+ > Model Context Protocol (MCP) client support for
9
+ > [Forgewisp](https://github.com/forgewisp/forgewisp-core) agents — connect an
10
+ > MCP server over Streamable HTTP and expose its tools as agent
11
+ > `FunctionDefinition`s, with JSON Schema validation, the risk-tier
12
+ > confirmation boundary, the audit log, and `runToolLoop` all applying
13
+ > unchanged.
14
+
15
+ MCP becomes just another source of `FunctionDefinition`s. This package
16
+ connects to an MCP server, lists its tools, and adapts each one into a
17
+ `FunctionDefinition` that is registered through the agent's existing
18
+ `registerFunction` path — so everything core already does for hand-authored
19
+ tools (registry, Ajv validation, two-phase executor, `onConfirmRequired`
20
+ invariant, audit log, abort propagation, the tool loop) applies to MCP tools
21
+ for free. **`@forgewisp/core` is not modified and is a types-only/peer
22
+ dependency here**; the only runtime dependency this package adds is
23
+ [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk).
24
+ Isolating the SDK in this opt-in package keeps users who don't need MCP from
25
+ pulling it into their bundle.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pnpm add @forgewisp/mcp @forgewisp/core
31
+ # or
32
+ npm install @forgewisp/mcp @forgewisp/core
33
+ ```
34
+
35
+ `@forgewisp/core` is a peer dependency. ESM and CommonJS builds are shipped
36
+ (`dist/index.mjs`, `dist/index.cjs`, `dist/index.d.ts`) — there is **no
37
+ self-contained browser global build**, since inlining the MCP SDK (which
38
+ relies on browser-native `fetch`/`EventSource`) into a single IIFE bundle
39
+ would be heavy and fragile. Consumers bundle it through their app bundler,
40
+ which resolves the externalized SDK and peer core. Node.js ≥ 18.
41
+
42
+ ## Quick start
43
+
44
+ ```ts
45
+ import { createAgent } from '@forgewisp/core';
46
+ import { registerMcpServer } from '@forgewisp/mcp';
47
+
48
+ const agent = createAgent({
49
+ llmEndpoint: 'https://api.openai.com/v1/chat/completions',
50
+ apiKey: process.env.OPENAI_API_KEY,
51
+ model: 'gpt-4o',
52
+ systemPrompt: 'You can use tools from the connected MCP server.',
53
+ // write/destructive tools require this — see "Risk tiers & confirmation".
54
+ onConfirmRequired: async (call) =>
55
+ window.confirm(`${call.functionName}: ${JSON.stringify(call.args)}`),
56
+ });
57
+
58
+ // Connect, list tools, adapt, and register them on the agent in one call.
59
+ const handle = await registerMcpServer(
60
+ agent,
61
+ {
62
+ name: 'my-server',
63
+ url: 'https://example.com/mcp',
64
+ apiKey: process.env.MCP_API_KEY, // optional; omitted entirely when unset
65
+ defaultTier: 'read',
66
+ tierOverrides: { sendEmail: 'write' }, // assign tiers per original tool name
67
+ hasConfirmation: true, // required if any tool resolves to write/destructive
68
+ },
69
+ );
70
+
71
+ console.log(handle.toolNames); // e.g. ['my-server__search', 'my-server__sendEmail']
72
+
73
+ const result = await agent.run('Search the docs for "MCP" and email me a summary.');
74
+
75
+ // Later: deregister all tools and disconnect.
76
+ await handle.close();
77
+ ```
78
+
79
+ Use the lower-level `createMcpTools` when you want the adapted
80
+ `FunctionDefinition[]` in hand without touching any agent — for custom
81
+ registration, a `ToolSet`, or rendering a tier-grouped tool list:
82
+
83
+ ```ts
84
+ import { createMcpTools } from '@forgewisp/mcp';
85
+
86
+ const { tools, authState, close, finishAuth } = await createMcpTools({
87
+ name: 'my-server',
88
+ url: 'https://example.com/mcp',
89
+ defaultTier: 'read',
90
+ });
91
+
92
+ for (const def of tools) agent.registerFunction(def); // register however you like
93
+ // `tools[i].name`, `.description`, and `.riskTier` are populated for UI rendering.
94
+
95
+ await close(); // disconnect when done
96
+ ```
97
+
98
+ ## API
99
+
100
+ ### `registerMcpServer(agent, config, options?): Promise<McpServerHandle>`
101
+
102
+ Connect to an MCP server, list its tools, adapt each into a
103
+ `FunctionDefinition`, and register them on `agent` (any object with
104
+ `registerFunction` / `deregisterFunction` — `ReturnType<typeof createAgent>`
105
+ satisfies this structurally). Returns a handle for lifecycle management.
106
+
107
+ - **Confirmation preflight.** Before registering anything, if any adapted
108
+ tool resolves to `write`/`destructive` and `config.hasConfirmation` is not
109
+ `true`, the client is closed and a clear error is thrown — so a tier
110
+ mismatch never leaves a partially-registered server. Core's own
111
+ registration-time invariant still fires as a backstop inside
112
+ `agent.registerFunction`.
113
+ - **OAuth.** When the first connect needs auth, no tools are registered yet
114
+ and the preflight is deferred until `handle.finishAuth(code)` lists the
115
+ tools (see [OAuth 2.1 + PKCE](#oauth-21--pkce)).
116
+
117
+ ### `createMcpTools(config, options?): Promise<McpToolsResult>`
118
+
119
+ The agent-free form: connect, list, and adapt without registering on any
120
+ agent. Returns `{ tools, authState, close, finishAuth }`. `tools` is empty
121
+ while `authState` is `'pending'` and is mutated in place once auth completes,
122
+ so the caller's reference stays valid. `close`/`finishAuth` are arrow
123
+ properties (they destructure cleanly).
124
+
125
+ ### `McpServerConfig`
126
+
127
+ | Field | Type | Default | Description |
128
+ | ------------------- | ------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
129
+ | `name` | `string` | — | Logical server name. Used as the default `toolNamePrefix` and the attribution suffix on each tool's description. |
130
+ | `url` | `string` | — | Streamable HTTP endpoint URL of the MCP server. |
131
+ | `apiKey` | `string?` | — | Bearer token sent as `Authorization`. Omitted entirely when unset (so no-auth/proxy endpoints work). Mutually exclusive with `authProvider`; if both are set, `authProvider` wins and `apiKey` is ignored. |
132
+ | `authProvider` | `OAuthClientProvider?` | — | Drives the full OAuth 2.1 + PKCE flow via the SDK. Takes precedence over `apiKey`. |
133
+ | `requestTimeoutMs` | `number?` | `60000` | Per-call timeout for `listTools`/`callTool`, in ms. |
134
+ | `toolNamePrefix` | `string?` | `config.name` | Prefix for the registered (prefixed) tool names. |
135
+ | `defaultTier` | `RiskTier?` | `'read'` | Risk tier for tools not listed in `tierOverrides`. |
136
+ | `tierOverrides` | `Record<string, RiskTier>?` | `{}` | Map of original MCP tool name → risk tier (overrides `defaultTier`). |
137
+ | `onlyTools` | `string[]?` | — | Allowlist of original MCP tool names to register; all others are skipped. `[]` registers none. |
138
+ | `excludeTools` | `string[]?` | — | Blocklist of original MCP tool names to skip. |
139
+ | `hasConfirmation` | `boolean?` | — | Required to be `true` when any resolved tool tier is `write`/`destructive`; `registerMcpServer` throws up front otherwise. |
140
+
141
+ ### `McpServerHandle`
142
+
143
+ | Field | Type | Description |
144
+ | ------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------- |
145
+ | `toolNames` | `string[]` | The prefixed tool names registered on the agent. Empty while `authState` is `'pending'`. |
146
+ | `authState` | `'authorized' \| 'pending'` | `'pending'` means OAuth is required and `finishAuth` (or resume) is needed before tools exist. |
147
+ | `finishAuth` | `(code: string) => Promise<void>` | Complete the OAuth redirect-back: exchange the code, reconnect, list tools, run preflight, register. |
148
+ | `close` | `() => Promise<void>` | Deregister all of this server's tools and disconnect. |
149
+
150
+ ### Re-exported types
151
+
152
+ The package re-exports the core types you need (`FunctionDefinition`,
153
+ `RiskTier`, `JSONSchema`, `JSONSchemaProperty`, `ToolContext`) and the SDK
154
+ OAuth shapes (`OAuthClientProvider`, `OAuthTokens`, `OAuthClientMetadata`,
155
+ `OAuthClientInformationMixed`) so you can import everything from
156
+ `@forgewisp/mcp`.
157
+
158
+ ## How adaptation works
159
+
160
+ - **Risk tiers come from config, not MCP hints.** MCP has no tier concept.
161
+ `defaultTier` + `tierOverrides` assign tiers; MCP
162
+ `annotations.readOnlyHint`/`destructiveHint` are informational only and
163
+ deliberately **not** auto-mapped — the consumer owns the security boundary.
164
+ - **Schema pass-through.** MCP `inputSchema` is full draft-07 JSON Schema.
165
+ Forgewisp's `JSONSchema` type is a narrow subset that is **not** widened; the
166
+ adapter casts the raw `inputSchema` at the boundary. Runtime is correct:
167
+ core's Ajv is `strict: false` and validates draft-07 (`$ref`/`enum`/`oneOf`
168
+ …), the compiled validator is cached per schema-object identity, and the
169
+ wire payload to the LLM carries the full schema. (External `$ref`s won't
170
+ resolve — they don't appear in well-formed MCP schemas.)
171
+ - **Name namespacing.** Registered names are `${prefix}__<sanitized>`,
172
+ sanitized to OpenAI's `^[A-Za-z0-9_-]{1,64}$`, with numeric suffixes on
173
+ collision. Tools are called by their **original** (un-prefixed) name so the
174
+ MCP server sees the right handler.
175
+ - **Result flattening.** MCP `callTool` results are reduced to a single
176
+ JSON-serializable value: `structuredContent` preferred, then a single
177
+ `text` item's `.text`, then joined text, else `{ content }`. `isError`
178
+ throws so the executor records `function_errored`.
179
+ - **Abort.** The parent run's `AbortSignal` (threaded in via `ToolContext`) is
180
+ forwarded to `client.callTool`, merged with the per-server
181
+ `requestTimeoutMs` — aborting the parent run aborts in-flight MCP calls, and
182
+ the timeout covers both the connect handshake and each `callTool`/`listTools`.
183
+
184
+ ## Risk tiers & confirmation
185
+
186
+ `@forgewisp/mcp` respects the same risk-tier boundary as `@forgewisp/core`.
187
+ **Tier discipline is a security boundary here too:**
188
+
189
+ - **`read`** tools run immediately, no confirmation.
190
+ - **`write` / `destructive`** tools require the consumer to configure
191
+ `onConfirmRequired` **and** pass `hasConfirmation: true` to
192
+ `registerMcpServer`. The package runs a preflight before registering
193
+ anything; core's own registration-time invariant fires as a backstop.
194
+
195
+ **Confirmation UI must be rendered only from the schema-validated
196
+ `PendingCall.args`** — never from raw LLM output. The executor validates args
197
+ against the JSON Schema before confirmation, so the args you get back are
198
+ guaranteed well-formed.
199
+
200
+ ## OAuth 2.1 + PKCE
201
+
202
+ `config.authProvider` (an SDK `OAuthClientProvider`) takes precedence over
203
+ `apiKey` and drives the full OAuth 2.1 authorization-code + PKCE flow via the
204
+ SDK: RFC 9728/8414 discovery, dynamic client registration (RFC 7591), token
205
+ exchange, and refresh. The consumer implements the provider and owns
206
+ browser-redirect plumbing + token storage. `client/auth.js` (and its
207
+ `UnauthorizedError`) is dynamically imported **only on the OAuth path** —
208
+ non-OAuth consumers never load the auth module.
209
+
210
+ The consumer-visible state machine is `McpAuthState = 'authorized' | 'pending'`:
211
+
212
+ - A first connect that needs auth returns `authState: 'pending'` with an
213
+ empty `tools` array instead of throwing — the SDK has already called
214
+ `provider.redirectToAuthorization`. The consumer redirects the user to the
215
+ authorization server, then calls `handle.finishAuth(code)` once the
216
+ redirect-back arrives.
217
+ - **Fresh transport on resume.** `finishAuth(code)` is not "reconnect the
218
+ spent transport." The SDK's `Client.connect` catches the `UnauthorizedError`
219
+ thrown during the redirect, calls `void this.close()` (which aborts the
220
+ transport's `AbortController` but does not null it), and rethrows — so a
221
+ second `start()` on that transport throws `"already started"`. The same
222
+ transport cannot be re-`connect`ed. So `finishAuth(code)` exchanges the code
223
+ on the spent transport (its `finishAuth` only calls `auth()`, independent of
224
+ `start()`), disconnects it, builds a **fresh** transport whose `connect`
225
+ reads `provider.tokens()` and succeeds, then re-lists tools and splices them
226
+ into the caller's `tools` array in place. The same fresh-transport pattern
227
+ backs `McpConnectOptions.authorizationCode` (the page-reload resume path:
228
+ exchange the code on a fresh transport *before* connecting).
229
+
230
+ ```ts
231
+ const handle = await registerMcpServer(agent, {
232
+ name: 'oauth-server',
233
+ url: 'https://example.com/mcp',
234
+ authProvider: myOAuthProvider, // your OAuthClientProvider implementation
235
+ defaultTier: 'read',
236
+ hasConfirmation: true,
237
+ });
238
+
239
+ if (handle.authState === 'pending') {
240
+ // The SDK already redirected the user to authorize. When they come back
241
+ // with `?code=...&state=...`, complete the flow:
242
+ await handle.finishAuth(authorizationCode);
243
+ }
244
+
245
+ // After a page reload, resume in one shot instead of re-prompting:
246
+ const handle = await registerMcpServer(agent, config, { authorizationCode: code });
247
+ ```
248
+
249
+ See `apps/mcp-demo` for a complete `OAuthClientProvider` backed by
250
+ `localStorage` (popup + same-tab fallback, `oauth-callback.html`, and a
251
+ load-resume path via `createMcpTools(config, { authorizationCode })`).
252
+
253
+ ## Security in production
254
+
255
+ Connecting an agent to an MCP server exposes it to that server's tools, their
256
+ schemas, and the text those tools return. Treat everything coming from the
257
+ server as untrusted input, the same way you would treat model output. The
258
+ risk-tier and confirmation machinery above is necessary but not sufficient —
259
+ in production, also mind these points:
260
+
261
+ - **Treat MCP servers as a trust boundary.** The agent will call a
262
+ server's tools based on model decisions, with whatever tiers you assigned.
263
+ Only connect to servers you control or have vetted. Audit which tools a
264
+ server exposes (`onlyTools` / `excludeTools`) before granting it
265
+ `write`/`destructive` tiers — do not blanket-map an untrusted server's
266
+ tools to `destructive`.
267
+ - **Tiers come from you, not the server.** MCP has no risk concept, and this
268
+ adapter deliberately does **not** auto-map `annotations.readOnlyHint` /
269
+ `destructiveHint`. The server controls its own annotations; trusting them
270
+ would let a server self-promote a tool to `read` and skip confirmation.
271
+ Decide tiers in `McpServerConfig` (`defaultTier` + `tierOverrides`) per
272
+ tool you actually intend to expose.
273
+ - **Tool descriptions and results are prompt-injection vectors.** Each adapted
274
+ tool's `description` is the server's own description text, and tool results
275
+ flow back into the conversation as model input. A compromised or hostile
276
+ server can craft descriptions/results that steer the model to call other
277
+ tools, exfiltrate data, or bypass confirmation. This is inherent to letting
278
+ a model consume server-supplied text — keep `write`/`destructive` tiers
279
+ narrow and confirmation strict, and prefer `onlyTools` allowlists over
280
+ registering everything a server exposes.
281
+ - **Confirm only from validated args.** Confirmation UI must be rendered from
282
+ the schema-validated `PendingCall.args`, never from raw model or
283
+ server-supplied text. The executor validates before confirming, so the args
284
+ you render are well-formed — don't re-render unvalidated server content in a
285
+ confirm dialog.
286
+ - **Don't ship long-lived secrets to the browser.** `apiKey` is sent as
287
+ `Authorization: Bearer …` and is visible to the page (and to anything with
288
+ access to the page's memory). For servers requiring user-scoped access,
289
+ prefer `authProvider` (OAuth) so tokens are obtained and refreshed per user
290
+ with a defined lifetime, rather than baking a shared key into the bundle.
291
+ - **Always use HTTPS.** Connect to `https://` endpoints only. Plain HTTP leaks
292
+ the bearer token, tool arguments, and tool results; it also lets a
293
+ network attacker tamper with the tool list and results the agent acts on.
294
+ - **Bound resource use.** Set a sane `requestTimeoutMs` so a hung or slow
295
+ server can't stall a run indefinitely — the timeout covers the connect
296
+ handshake, `listTools`, and each `callTool`. Aborting the parent run (the
297
+ `AbortSignal` threaded in via `ToolContext`) cancels in-flight MCP calls.
298
+ - **Atomic registration.** The `hasConfirmation` preflight runs before any
299
+ tool is registered, and a partial-registration failure rolls back already
300
+ registered tools — so a tier mismatch never leaves a half-registered
301
+ server whose tools the model could already call. Keep `hasConfirmation: true`
302
+ whenever any tool can be `write`/`destructive`.
303
+ - **OAuth token storage is yours to harden.** The SDK calls your
304
+ `OAuthClientProvider` for token persistence; the demo uses `localStorage`,
305
+ which is reachable by any script on your origin (XSS). For real deployments,
306
+ store tokens somewhere less XSS-exposed and scope them per server name.
307
+
308
+ ## Tests
309
+
310
+ ```bash
311
+ pnpm install
312
+ pnpm --filter @forgewisp/mcp test # vitest run
313
+ ```
314
+
315
+ The adapter is layered so the mapping is unit-testable without a network:
316
+ `adaptMcpTool` / `adaptMcpTools` are pure given a `McpClient` (a plain fake
317
+ implementing `callTool` is enough). `register-mcp-server.test.ts` and
318
+ `oauth.test.ts` use the SDK's `InMemoryTransport` via an `@internal`
319
+ `options.transport` injection seam on `createMcpTools` / `registerMcpServer`
320
+ (a `Transport` or a `() => Transport` factory — the factory form lets the
321
+ OAuth tests share one in-memory pipe across the gated transports that
322
+ `finishAuth`/resume build). This seam is not part of the stable public
323
+ contract.
324
+
325
+ ## License
326
+
327
+ MIT