diff --git a/.gitignore b/.gitignore index 73d103a..45dd063 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ dist **/.claude/settings.local.json /public +# SvelteKit build cache for test app +# Example app artifacts +examples/inspector-svelte/.svelte-kit/ +examples/inspector-svelte/pnpm-lock.yaml diff --git a/README.md b/README.md index b247654..72fa87e 100644 --- a/README.md +++ b/README.md @@ -4,25 +4,33 @@ -[![GitHub last commit](https://img.shields.io/github/last-commit/modelcontextprotocol/use-mcp?logo=github&style=flat&label=​)](https://github.com/modelcontextprotocol/use-mcp)  [![npm](https://img.shields.io/npm/v/use-mcp?label=​&logo=npm)](https://www.npmjs.com/package/use-mcp) ![GitHub License](https://img.shields.io/github/license/modelcontextprotocol/use-mcp) +> [!NOTE] +> This package includes Svelte support. Use the Svelte adapter via `@gary149/use-mcp/svelte` and see the Svelte inspector example in `examples/inspector-svelte`. -A lightweight React hook for connecting to [Model Context Protocol (MCP)](https://github.com/modelcontextprotocol) servers. Simplifies authentication and tool calling for AI systems implementing the MCP standard. +[![GitHub last commit](https://img.shields.io/github/last-commit/gary149/use-mcp?logo=github&style=flat&label=)](https://github.com/gary149/use-mcp)  [![npm](https://img.shields.io/npm/v/%40gary149%2Fuse-mcp?label=&logo=npm)](https://www.npmjs.com/package/@gary149/use-mcp) ![GitHub License](https://img.shields.io/github/license/gary149/use-mcp) + +A lightweight client for connecting to [Model Context Protocol (MCP)](https://github.com/modelcontextprotocol) servers. Provides a React hook and a Svelte store adapter to simplify authentication and tool calling. Try it out: [Chat Demo](https://chat.use-mcp.dev) | [MCP Inspector](https://inspector.use-mcp.dev) | [Cloudflare Workers AI Playground](https://playground.ai.cloudflare.com/) +Examples in this repo: +- [examples/chat-ui](examples/chat-ui) – React chat interface using `@gary149/use-mcp` +- [examples/inspector](examples/inspector) – React MCP inspector +- [examples/inspector-svelte](examples/inspector-svelte) – SvelteKit inspector with per‑tool inputs + ## Installation ```bash -npm install use-mcp +npm install @gary149/use-mcp # or -pnpm add use-mcp +pnpm add @gary149/use-mcp # or -yarn add use-mcp +yarn add @gary149/use-mcp ``` ## Development -To run the development environment with all examples and servers: +To run the development environment with the React examples and servers: ```bash pnpm dev @@ -34,6 +42,15 @@ This starts: - **Hono MCP Server**: http://localhost:5101 - Example MCP server - **CF Agents MCP Server**: http://localhost:5102 - Cloudflare Workers AI MCP server +Svelte inspector example: + +```bash +cd examples/inspector-svelte +pnpm install +pnpm dev +``` +Then open the local URL shown by Vite and connect to an MCP server (e.g. https://huggingface.co/mcp). The UI lists tools and provides input fields generated from each tool's JSON schema. + ### Testing Integration tests are located in the `test/` directory and run headlessly by default: @@ -49,18 +66,18 @@ cd test && pnpm test:ui # Run tests with interactive UI - 🔄 Automatic connection management with reconnection and retries - 🔐 OAuth authentication flow handling with popup and fallback support -- 📦 Simple React hook interface for MCP integration +- 📦 Simple React hook and Svelte store adapters for MCP integration - 🧰 Full support for MCP tools, resources, and prompts - 📄 Access server resources and read their contents - 💬 Use server-provided prompt templates - 🧰 TypeScript types for editor assistance and type checking - 📝 Comprehensive logging for debugging -- 🌐 Works with both HTTP and SSE (Server-Sent Events) transports +- 🌐 Works with both HTTP and SSE (Server-Sent Events) transports (HTTP streaming recommended) -## Quick Start +## Quick Start (React) ```tsx -import { useMcp } from 'use-mcp/react' +import { useMcp } from '@gary149/use-mcp/react' function MyAIComponent() { const { @@ -146,6 +163,45 @@ function MyAIComponent() { } ``` +## Quick Start (Svelte/SvelteKit) + +```ts +// src/lib/mcp.ts +import { browser } from '$app/environment' +import { createMcp } from '@gary149/use-mcp/svelte' + +export const mcp = browser ? createMcp({ + url: 'https://your-mcp-server.com', + clientName: 'My App', + autoReconnect: true, + // transportType: 'http', // recommended; SSE is legacy +}) : undefined +``` + +```svelte + + + +{#if mcp} + {#if $mcp.state === 'failed'} +

Connection failed: {$mcp.error}

+ + + {:else if $mcp.state !== 'ready'} +

Connecting to AI service…

+ {:else} +

Available Tools: {$mcp.tools.length}

+ + {/if} +{:else} +

Loading…

+{/if} +``` + ## Setting Up OAuth Callback To handle the OAuth authentication flow, you need to set up a callback endpoint in your app. @@ -156,7 +212,7 @@ To handle the OAuth authentication flow, you need to set up a callback endpoint // App.tsx with React Router import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' import { useEffect } from 'react' -import { onMcpAuthorization } from 'use-mcp' +import { onMcpAuthorization } from '@gary149/use-mcp' function OAuthCallback() { useEffect(() => { @@ -188,7 +244,7 @@ function App() { ```tsx // pages/oauth/callback.tsx import { useEffect } from 'react' -import { onMcpAuthorization } from 'use-mcp' +import { onMcpAuthorization } from '@gary149/use-mcp' export default function OAuthCallbackPage() { useEffect(() => { @@ -204,6 +260,22 @@ export default function OAuthCallbackPage() { } ``` +### With SvelteKit + +```svelte + + + +

Authenticating…

+

This window should close automatically.

+``` + +Note: When using HTTP streaming transport across origins, ensure your MCP server CORS configuration allows and exposes the `Mcp-Session-Id` header so the browser can maintain the session. If your OAuth callback is hosted on a different origin (e.g., auth subdomain), pass `allowedOrigins: ['https://auth.example.com']` to `createMcp(...)` so the popup callback message is accepted. + ## API Reference ### `useMcp` Hook @@ -253,4 +325,4 @@ function useMcp(options: UseMcpOptions): UseMcpResult ## License -MIT \ No newline at end of file +MIT diff --git a/examples/inspector-svelte/package.json b/examples/inspector-svelte/package.json new file mode 100644 index 0000000..c47bbca --- /dev/null +++ b/examples/inspector-svelte/package.json @@ -0,0 +1,26 @@ +{ + "name": "inspector-svelte", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^6.0.0", + "@sveltejs/kit": "^2.22.0", + "@sveltejs/vite-plugin-svelte": "^6.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.8.2", + "vite": "^7.1.4" + }, + "dependencies": { + "@gary149/use-mcp": "^0.1.2" + } +} diff --git a/examples/inspector-svelte/src/app.d.ts b/examples/inspector-svelte/src/app.d.ts new file mode 100644 index 0000000..8cb1955 --- /dev/null +++ b/examples/inspector-svelte/src/app.d.ts @@ -0,0 +1,12 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} +export {}; + diff --git a/examples/inspector-svelte/src/app.html b/examples/inspector-svelte/src/app.html new file mode 100644 index 0000000..7cfc00c --- /dev/null +++ b/examples/inspector-svelte/src/app.html @@ -0,0 +1,13 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + + diff --git a/examples/inspector-svelte/src/lib/mcp.ts b/examples/inspector-svelte/src/lib/mcp.ts new file mode 100644 index 0000000..aa7fd83 --- /dev/null +++ b/examples/inspector-svelte/src/lib/mcp.ts @@ -0,0 +1,11 @@ +import { browser } from '$app/environment' +import { createMcp } from '@gary149/use-mcp/svelte' + +export const mcp = browser + ? createMcp({ + url: 'https://huggingface.co/mcp?login', + clientName: 'My App', + autoReconnect: true, + transportType: 'http', + }) + : undefined diff --git a/examples/inspector-svelte/src/routes/+layout.svelte b/examples/inspector-svelte/src/routes/+layout.svelte new file mode 100644 index 0000000..554a539 --- /dev/null +++ b/examples/inspector-svelte/src/routes/+layout.svelte @@ -0,0 +1,21 @@ + + MCP Inspector — Svelte + + +
+
+
+

MCP Inspector

+ Svelte + use-mcp +
+
+
+ +
+
diff --git a/examples/inspector-svelte/src/routes/+page.svelte b/examples/inspector-svelte/src/routes/+page.svelte new file mode 100644 index 0000000..7c21041 --- /dev/null +++ b/examples/inspector-svelte/src/routes/+page.svelte @@ -0,0 +1,189 @@ + + +{#if !mcp} +
Loading client…
+{:else} + {#if $mcp.state === 'failed'} +
+
Connection failed
+
{$mcp.error}
+
+ + +
+
+ {:else if $mcp.state !== 'ready'} +
+
Connecting to AI service…
+
State: {$mcp.state}
+
+ {:else} +
+
+

Available Tools

+

{$mcp.tools.length} tool{ $mcp.tools.length === 1 ? '' : 's' } available

+
+
+ + +
+
+ + {#if $mcp.tools.length > 0} + + {:else} +
No tools available.
+ {/if} + {/if} +{/if} diff --git a/examples/inspector-svelte/src/routes/oauth/callback/+page.svelte b/examples/inspector-svelte/src/routes/oauth/callback/+page.svelte new file mode 100644 index 0000000..e8c1e6c --- /dev/null +++ b/examples/inspector-svelte/src/routes/oauth/callback/+page.svelte @@ -0,0 +1,9 @@ + + +

Authenticating…

+

This window should close automatically.

+ diff --git a/examples/inspector-svelte/svelte.config.js b/examples/inspector-svelte/svelte.config.js new file mode 100644 index 0000000..c47e6a7 --- /dev/null +++ b/examples/inspector-svelte/svelte.config.js @@ -0,0 +1,13 @@ +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter() + } +}; + +export default config; + diff --git a/examples/inspector-svelte/tsconfig.json b/examples/inspector-svelte/tsconfig.json new file mode 100644 index 0000000..f4e5d10 --- /dev/null +++ b/examples/inspector-svelte/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["svelte"] + } +} + diff --git a/examples/inspector-svelte/vite.config.ts b/examples/inspector-svelte/vite.config.ts new file mode 100644 index 0000000..c010b71 --- /dev/null +++ b/examples/inspector-svelte/vite.config.ts @@ -0,0 +1,7 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()] +}); + diff --git a/package.json b/package.json index 6a7cd6b..da62761 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,11 @@ { - "name": "use-mcp", - "repository": "https://github.com/modelcontextprotocol/use-mcp", - "version": "0.0.21", + "name": "@gary149/use-mcp", + "repository": { + "type": "git", + "url": "git+https://github.com/gary149/use-mcp.git" + }, + "version": "0.1.3", + "license": "MIT", "type": "module", "files": [ "dist", @@ -18,14 +22,26 @@ "types": "./dist/react/index.d.ts", "require": "./dist/react/index.js", "import": "./dist/react/index.js" + }, + "./svelte": { + "types": "./dist/svelte/index.d.ts", + "require": "./dist/svelte/index.js", + "import": "./dist/svelte/index.js" } }, + "svelte": "./dist/svelte/index.js", + "sideEffects": false, + "homepage": "https://github.com/gary149/use-mcp#readme", + "bugs": { + "url": "https://github.com/gary149/use-mcp/issues" + }, "scripts": { "install:all": "concurrently 'pnpm install' 'cd examples/chat-ui && pnpm install' 'cd examples/inspector && pnpm install' 'cd examples/servers/hono-mcp && pnpm install' 'cd examples/servers/cf-agents && pnpm install'", "dev": "concurrently 'pnpm:build:watch' 'cd examples/chat-ui && pnpm dev' 'cd examples/inspector && pnpm dev' 'sleep 1 && cd examples/servers/hono-mcp && pnpm dev' 'sleep 2 && cd examples/servers/cf-agents && pnpm dev'", "deploy:all": "concurrently 'pnpm build:site && pnpm deploy:site' 'cd examples/chat-ui && pnpm run deploy' 'cd examples/inspector && pnpm run deploy'", "build:watch": "tsup --watch", "build": "tsup", + "prepublishOnly": "pnpm build", "check": "prettier --check . && tsc", "prettier:fix": "prettier --write .", "fix:oranda": "sed -i 's/```tsx/```ts/g' README.md", @@ -33,6 +49,9 @@ "deploy:site": "npx wrangler deploy", "prepare": "husky" }, + "publishConfig": { + "access": "public" + }, "dependencies": { "@modelcontextprotocol/sdk": "^1.13.3", "strict-url-sanitise": "^0.0.1" @@ -44,15 +63,20 @@ "husky": "^9.1.7", "prettier": "^3.5.3", "react": "^19.0.0", + "svelte": "^5.0.0", "tsup": "^8.4.0", "tsx": "^4.19.3", "typescript": "^5.8.2", "wrangler": "^4.20.2" }, + "peerDependencies": { + "svelte": "^4.2.0 || ^5.0.0" + }, "tsup": { "entry": [ "src/index.ts", - "src/react/index.ts" + "src/react/index.ts", + "src/svelte/index.ts" ], "format": [ "esm" @@ -62,6 +86,7 @@ "outDir": "dist", "external": [ "react", + "svelte", "@modelcontextprotocol/sdk" ] }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36932dc..34e3b3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: react: specifier: ^19.0.0 version: 19.0.0 + svelte: + specifier: ^5.0.0 + version: 5.38.7 tsup: specifier: ^8.4.0 version: 8.4.0(tsx@4.19.3)(typescript@5.8.2) @@ -524,6 +527,9 @@ packages: resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -535,6 +541,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} @@ -644,6 +653,11 @@ packages: cpu: [x64] os: [win32] + '@sveltejs/acorn-typescript@1.0.5': + resolution: {integrity: sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==} + peerDependencies: + acorn: ^8.9.0 + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -685,6 +699,10 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + as-table@1.0.55: resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} @@ -697,6 +715,10 @@ packages: axios@1.10.0: resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==} + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -752,6 +774,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -900,6 +926,12 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.1.0: + resolution: {integrity: sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -1055,6 +1087,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1079,6 +1114,9 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} @@ -1088,6 +1126,9 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + magic-string@0.30.18: + resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1415,6 +1456,10 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + svelte@5.38.7: + resolution: {integrity: sha512-1ld9TPZSdUS3EtYGQzisU2nhwXoIzNQcZ71IOU9fEmltaUofQnVfW5CQuhgM/zFsZ43arZXS1BRKi0MYgUV91w==} + engines: {node: '>=18'} + tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} @@ -1579,6 +1624,9 @@ packages: youch@3.3.4: resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==} + zimmerframe@1.1.2: + resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} + zod-to-json-schema@3.24.6: resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==} peerDependencies: @@ -1883,12 +1931,19 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/set-array@1.2.1': {} '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -1976,6 +2031,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.35.0': optional: true + '@sveltejs/acorn-typescript@1.0.5(acorn@8.14.0)': + dependencies: + acorn: 8.14.0 + '@types/estree@1.0.6': {} '@types/react@19.0.12': @@ -2010,6 +2069,8 @@ snapshots: any-promise@1.3.0: {} + aria-query@5.3.2: {} + as-table@1.0.55: dependencies: printable-characters: 1.0.42 @@ -2028,6 +2089,8 @@ snapshots: transitivePeerDependencies: - debug + axobject-query@4.1.0: {} + balanced-match@1.0.2: {} blake3-wasm@2.1.5: {} @@ -2089,6 +2152,8 @@ snapshots: clone@1.0.4: optional: true + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2264,6 +2329,12 @@ snapshots: escape-html@1.0.3: {} + esm-env@1.2.2: {} + + esrap@2.1.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + etag@1.8.1: {} eventsource-parser@3.0.3: {} @@ -2433,6 +2504,10 @@ snapshots: is-promise@4.0.0: {} + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.6 + isexe@2.0.0: {} jackspeak@3.4.3: @@ -2451,12 +2526,18 @@ snapshots: load-tsconfig@0.2.5: {} + locate-character@3.0.0: {} + lodash.sortby@4.7.0: {} lodash@4.17.21: {} lru-cache@10.4.3: {} + magic-string@0.30.18: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} media-typer@1.1.0: {} @@ -2801,6 +2882,23 @@ snapshots: dependencies: has-flag: 4.0.0 + svelte@5.38.7: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.0 + '@sveltejs/acorn-typescript': 1.0.5(acorn@8.14.0) + '@types/estree': 1.0.6 + acorn: 8.14.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + clsx: 2.1.1 + esm-env: 1.2.2 + esrap: 2.1.0 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.18 + zimmerframe: 1.1.2 + tar@7.4.3: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -2981,6 +3079,8 @@ snapshots: mustache: 4.2.0 stacktracey: 2.1.8 + zimmerframe@1.1.2: {} + zod-to-json-schema@3.24.6(zod@3.25.67): dependencies: zod: 3.25.67 diff --git a/src/react/types.ts b/src/react/types.ts index b80df54..e094411 100644 --- a/src/react/types.ts +++ b/src/react/types.ts @@ -30,6 +30,12 @@ export type UseMcpOptions = { transportType?: 'auto' | 'http' | 'sse' /** Prevent automatic authentication popup on initial connection (default: false) */ preventAutoAuth?: boolean + /** + * Allow receiving OAuth callback messages from additional origins. + * By default only the same-origin as the app is accepted. Add entries + * like 'https://auth.example.com' if your login page is hosted elsewhere. + */ + allowedOrigins?: string[] /** * Callback function that is invoked just before the authentication popup window is opened. * @param url The URL that will be opened in the popup. diff --git a/src/svelte/createMcp.ts b/src/svelte/createMcp.ts new file mode 100644 index 0000000..c4a6dda --- /dev/null +++ b/src/svelte/createMcp.ts @@ -0,0 +1,640 @@ +import { readable, type Readable } from 'svelte/store' +import { + CallToolResultSchema, + JSONRPCMessage, + ListToolsResultSchema, + ListResourcesResultSchema, + ReadResourceResultSchema, + ListPromptsResultSchema, + GetPromptResultSchema, + type Resource, + type ResourceTemplate, + type Prompt, +} from '@modelcontextprotocol/sdk/types.js' +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { auth, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import { sanitizeUrl } from 'strict-url-sanitise' + +import { BrowserOAuthClientProvider } from '../auth/browser-provider.js' +import { assert } from '../utils/assert.js' +import type { UseMcpOptions, UseMcpResult } from '../react/types.js' + +const DEFAULT_RECONNECT_DELAY = 3000 +const DEFAULT_RETRY_DELAY = 5000 +const AUTH_TIMEOUT = 5 * 60 * 1000 +type TransportType = 'http' | 'sse' + +/** + * Factory returning a Svelte store whose value mirrors UseMcpResult. + * Methods are exposed on the returned object alongside the `subscribe` method. + */ +export function createMcp(options: UseMcpOptions): Readable & Omit & { + // Explicit methods to help inference when consuming + callTool: UseMcpResult['callTool'] + listResources: UseMcpResult['listResources'] + readResource: UseMcpResult['readResource'] + listPrompts: UseMcpResult['listPrompts'] + getPrompt: UseMcpResult['getPrompt'] + retry: UseMcpResult['retry'] + disconnect: UseMcpResult['disconnect'] + authenticate: UseMcpResult['authenticate'] + clearStorage: UseMcpResult['clearStorage'] +} { + const { + url, + clientName, + clientUri, + callbackUrl = typeof window !== 'undefined' + ? sanitizeUrl(new URL('/oauth/callback', window.location.origin).toString()) + : '/oauth/callback', + storageKeyPrefix = 'mcp:auth', + clientConfig = {}, + customHeaders = {}, + debug = false, + autoRetry = false, + autoReconnect = DEFAULT_RECONNECT_DELAY, + transportType = 'auto', + preventAutoAuth = false, + onPopupWindow, + allowedOrigins = [], + } = options + + // --- Internal mutable refs (not reactive) --- + const serverUrl = new URL(sanitizeUrl(url)).toString() + let client: Client | null = null + let transport: Transport | null = null + let authProvider: BrowserOAuthClientProvider | null = null + let connecting = false + let isMounted = false + let connectAttempt = 0 + let authTimeout: ReturnType | null = null + let autoRetryTimer: ReturnType | null = null + let autoReconnectRef: boolean | number = autoReconnect + let stateRef: UseMcpResult['state'] = 'discovering' + let successfulTransportRef: TransportType | null = null + + // --- Helper: build initial value --- + const initialValue: UseMcpResult = { + state: 'discovering', + tools: [], + resources: [], + resourceTemplates: [], + prompts: [], + error: undefined, + authUrl: undefined, + log: [], + // Placeholder methods; replaced after store creation to capture `set` + callTool: async () => { throw new Error('MCP client not ready') }, + listResources: async () => { throw new Error('MCP client not ready') }, + readResource: async () => { throw new Error('MCP client not ready') }, + listPrompts: async () => { throw new Error('MCP client not ready') }, + getPrompt: async () => { throw new Error('MCP client not ready') }, + retry: () => {}, + disconnect: () => {}, + authenticate: () => {}, + clearStorage: () => {}, + } + + // Local mutable snapshot of the value we publish to subscribers + let current: UseMcpResult = { ...initialValue } + + // --- Logging --- + function addLog(level: UseMcpResult['log'][number]['level'], message: string, ...args: unknown[]) { + if (level === 'debug' && !debug) return + const fullMessage = args.length > 0 ? `${message} ${args.map((arg) => safeStringify(arg)).join(' ')}` : message + try { console[level](`[useMcp] ${fullMessage}`) } catch {} + if (!isMounted) return + const log = [...current.log.slice(-100), { level, message: fullMessage, timestamp: Date.now() }] + setValue({ log }) + } + + function safeStringify(v: unknown): string { + try { return JSON.stringify(v) } catch { return String(v) } + } + + function setValue(patch: Partial) { + current = { ...current, ...patch } + // Keep stateRef in sync whenever state is updated via setValue + if (typeof patch.state !== 'undefined') { + stateRef = patch.state as UseMcpResult['state'] + scheduleAutoRetryIfNeeded() + } + set(current) + } + + function setState(state: UseMcpResult['state']) { + stateRef = state + setValue({ state }) + scheduleAutoRetryIfNeeded() + } + + function clearAuthTimeout() { + if (authTimeout) { clearTimeout(authTimeout); authTimeout = null } + } + + async function doDisconnect(quiet = false) { + if (!quiet) addLog('info', 'Disconnecting...') + connecting = false + clearAuthTimeout() + const t = transport + client = null + transport = null + if (isMounted && !quiet) { + setValue({ + state: 'discovering', + tools: [], resources: [], resourceTemplates: [], prompts: [], + error: undefined, authUrl: undefined, + }) + } + if (t) { + try { await t.close(); if (!quiet) addLog('debug', 'Transport closed') } catch (e) { if (!quiet) addLog('warn', 'Error closing transport:', e as Error) } + } + } + + function scheduleAutoRetryIfNeeded() { + if (autoRetryTimer) { clearTimeout(autoRetryTimer); autoRetryTimer = null } + if (stateRef === 'failed' && autoRetry && connectAttempt > 0) { + const delay = typeof autoRetry === 'number' ? autoRetry : DEFAULT_RETRY_DELAY + addLog('info', `Connection failed, auto-retrying in ${delay}ms...`) + autoRetryTimer = setTimeout(() => { + if (isMounted && stateRef === 'failed') { + retry() + } + }, delay) + } + } + + function failConnection(errorMessage: string, connectionError?: Error) { + addLog('error', errorMessage, connectionError ?? '') + if (isMounted) { + setValue({ state: 'failed', error: errorMessage }) + const manualUrl = authProvider?.getLastAttemptedAuthUrl() || undefined + if (manualUrl) { + setValue({ authUrl: manualUrl }) + addLog('info', 'Manual authentication URL may be available.', manualUrl) + } + } + connecting = false + } + + async function connect() { + if (connecting) { addLog('debug', 'Connection attempt already in progress.'); return } + if (!isBrowser()) { addLog('debug', 'Not in browser; skipping connect.'); return } + if (!isMounted) { addLog('debug', 'Connect called before mount; aborting.'); return } + + connecting = true + connectAttempt += 1 + successfulTransportRef = null + setValue({ error: undefined, authUrl: undefined }) + setState('discovering') + addLog('info', `Connecting attempt #${connectAttempt} to ${serverUrl}...`) + + // init provider/client + if (!authProvider) { + authProvider = new BrowserOAuthClientProvider(serverUrl, { + storageKeyPrefix, + clientName, + clientUri, + callbackUrl, + preventAutoAuth, + onPopupWindow, + }) + addLog('debug', 'BrowserOAuthClientProvider initialized in connect.') + } + if (!client) { + client = new Client({ name: clientConfig.name || 'use-mcp-svelte-client', version: clientConfig.version || '0.1.0' }, { capabilities: {} }) + addLog('debug', 'MCP Client initialized in connect.') + } + + const tryConnectWithTransport = async (kind: TransportType): Promise<'success' | 'auth_redirect' | 'failed'> => { + addLog('info', `Attempting connection with ${kind.toUpperCase()} transport...`) + if (stateRef !== 'authenticating') setState('connecting') + + let transportInstance: Transport + try { + assert(authProvider, 'Auth Provider must be initialized') + assert(client, 'Client must be initialized') + if (transport) { + await transport.close().catch((e: any) => addLog('warn', `Error closing previous transport: ${(e as Error)?.message || e}`)) + transport = null + } + + const baseOpts = { authProvider } as const + const targetUrl = new URL(serverUrl) + addLog('debug', `Creating ${kind.toUpperCase()} transport for URL: ${targetUrl.toString()}`) + + if (kind === 'http') { + addLog('debug', 'Creating StreamableHTTPClientTransport...') + transportInstance = new StreamableHTTPClientTransport(targetUrl, { + ...baseOpts, + requestInit: { headers: { ...customHeaders } }, + }) + } else { + addLog('debug', 'Creating SSEClientTransport...') + transportInstance = new SSEClientTransport(targetUrl, { + ...baseOpts, + requestInit: { headers: { Accept: 'text/event-stream', ...customHeaders } }, + }) + } + transport = transportInstance + } catch (err) { + failConnection(`Failed to create ${kind.toUpperCase()} transport: ${err instanceof Error ? err.message : String(err)}`, + err instanceof Error ? err : undefined) + return 'failed' + } + + transportInstance.onmessage = (message: JSONRPCMessage) => { + addLog('debug', `[Transport] Received: ${safeStringify(message)}`) + // @ts-ignore + client?.handleMessage?.(message) + } + transportInstance.onerror = (err: Error) => { + addLog('warn', `Transport error event (${kind.toUpperCase()}):`, err) + failConnection(`Transport error (${kind.toUpperCase()}): ${err.message}`, err) + } + transportInstance.onclose = () => { + if (!isMounted || connecting) return + addLog('info', `Transport connection closed (${successfulTransportRef || 'unknown'} type).`) + const currentState = stateRef + const ar = autoReconnectRef + if (currentState === 'ready' && ar) { + const delay = typeof ar === 'number' ? ar : DEFAULT_RECONNECT_DELAY + addLog('info', `Attempting to reconnect in ${delay}ms...`) + setState('connecting') + setTimeout(() => { if (isMounted) connect() }, delay) + } else if (currentState !== 'failed' && currentState !== 'authenticating') { + failConnection('Connection closed unexpectedly.') + } + } + + try { + addLog('info', `Connecting client via ${kind.toUpperCase()}...`) + await client!.connect(transportInstance) + addLog('info', `Client connected via ${kind.toUpperCase()}. Loading tools, resources, and prompts...`) + successfulTransportRef = kind + setState('loading') + + const toolsResponse = await client!.request({ method: 'tools/list' }, ListToolsResultSchema) + let resourcesResponse: { resources: Resource[]; resourceTemplates?: ResourceTemplate[] } = { resources: [], resourceTemplates: [] } + try { resourcesResponse = await client!.request({ method: 'resources/list' }, ListResourcesResultSchema) } catch (e) { addLog('debug', 'Server does not support resources/list method', e as Error) } + let promptsResponse: { prompts: Prompt[] } = { prompts: [] } + try { promptsResponse = await client!.request({ method: 'prompts/list' }, ListPromptsResultSchema) } catch (e) { addLog('debug', 'Server does not support prompts/list method', e as Error) } + + if (isMounted) { + setValue({ + tools: toolsResponse.tools, + resources: resourcesResponse.resources, + resourceTemplates: Array.isArray(resourcesResponse.resourceTemplates) ? resourcesResponse.resourceTemplates : [], + prompts: promptsResponse.prompts, + state: 'ready', + }) + addLog('info', `Ready. Tools: ${toolsResponse.tools.length}, Resources: ${resourcesResponse.resources.length}, Prompts: ${promptsResponse.prompts.length}`) + } + return 'success' + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + const errorInstance = err instanceof Error ? err : new Error(String(err)) + addLog('warn', `Error during connect (${kind.toUpperCase()}): ${errorMessage}`) + + if ( + errorInstance instanceof UnauthorizedError || + errorMessage.includes('Unauthorized') || errorMessage.includes('401') + ) { + if (preventAutoAuth) { + setState('pending_auth') + try { + assert(authProvider, 'Auth Provider must be initialized') + const authUrl = await authProvider.prepareAuthorizationUrl(new URL('about:blank')) + setValue({ authUrl }) + addLog('info', 'Authentication required. Manual URL prepared (preventAutoAuth=true).') + return 'failed' + } catch (prepErr) { + failConnection(`Failed to prepare authorization URL: ${prepErr instanceof Error ? prepErr.message : String(prepErr)}`, + prepErr instanceof Error ? prepErr : undefined) + return 'failed' + } + } + + setState('authenticating') + clearAuthTimeout() + authTimeout = setTimeout(() => { + if (!isMounted) return + failConnection('Authentication timed out. Please try again.') + }, AUTH_TIMEOUT) + + try { + assert(authProvider, 'Auth Provider must be initialized') + const authResult = await auth(authProvider, { serverUrl }) + if (!isMounted) return 'failed' + if (authResult === 'AUTHORIZED') { + addLog('info', 'Authentication successful. Re-attempting connection...') + clearAuthTimeout() + connecting = false + connect() + return 'failed' + } else if (authResult === 'REDIRECT') { + addLog('info', 'Redirecting for authentication. Waiting for callback...') + return 'auth_redirect' + } + } catch (sdkAuthError) { + if (!isMounted) return 'failed' + clearAuthTimeout() + failConnection(`Failed to initiate authentication: ${sdkAuthError instanceof Error ? sdkAuthError.message : String(sdkAuthError)}`, + sdkAuthError instanceof Error ? sdkAuthError : undefined) + return 'failed' + } + } + + failConnection(`Failed to connect via ${kind.toUpperCase()}: ${errorMessage}`, errorInstance) + return 'failed' + } + } + + let finalStatus: 'success' | 'auth_redirect' | 'failed' = 'failed' + if (transportType === 'sse') { + finalStatus = await tryConnectWithTransport('sse') + } else if (transportType === 'http') { + finalStatus = await tryConnectWithTransport('http') + } else { + const httpResult = await tryConnectWithTransport('http') + if (httpResult !== 'success' && httpResult !== 'auth_redirect') { + addLog('info', 'HTTP connect failed; attempting SSE fallback...') + finalStatus = await tryConnectWithTransport('sse') + } else { + finalStatus = httpResult + } + } + + if (finalStatus === 'success' || finalStatus === 'failed') connecting = false + addLog('debug', `Connection sequence finished with status: ${finalStatus}`) + } + + async function callTool(name: string, args?: Record) { + if (stateRef !== 'ready' || !client) { + throw new Error(`MCP client is not ready (current state: ${current.state}). Cannot call tool "${name}".`) + } + addLog('info', `Calling tool: ${name}`, args) + try { + const result = await client.request({ method: 'tools/call', params: { name, arguments: args } }, CallToolResultSchema) + addLog('info', `Tool "${name}" call successful.`) + return result + } catch (err) { + addLog('error', `Error calling tool "${name}": ${err instanceof Error ? err.message : String(err)}`, err as Error) + const errorInstance = err instanceof Error ? err : new Error(String(err)) + if ( + errorInstance instanceof UnauthorizedError || + errorInstance.message.includes('Unauthorized') || + errorInstance.message.includes('401') + ) { + addLog('warn', 'Tool call unauthorized, attempting re-authentication...') + setState('authenticating') + clearAuthTimeout() + authTimeout = setTimeout(() => { + if (!isMounted) return + failConnection('Authentication timed out. Please try again.') + }, AUTH_TIMEOUT) + try { + assert(authProvider, 'Auth Provider not available for tool re-auth') + const authResult = await auth(authProvider, { serverUrl }) + if (!isMounted) return + if (authResult === 'AUTHORIZED') { + addLog('info', 'Re-authentication successful. Reconnecting...') + clearAuthTimeout() + connecting = false + connect() + } else if (authResult === 'REDIRECT') { + addLog('info', 'Redirecting for re-authentication for tool call.') + } + } catch (sdkAuthError) { + if (!isMounted) return + clearAuthTimeout() + failConnection(`Re-authentication failed: ${sdkAuthError instanceof Error ? sdkAuthError.message : String(sdkAuthError)}`, + sdkAuthError instanceof Error ? sdkAuthError : undefined) + } + } + if (current.state !== 'authenticating') throw err as any + return undefined as any + } + } + + async function listResources() { + if (stateRef !== 'ready' || !client) { + throw new Error(`MCP client is not ready (current state: ${current.state}). Cannot list resources.`) + } + addLog('info', 'Listing resources...') + try { + const resourcesResponse = await client.request({ method: 'resources/list' }, ListResourcesResultSchema) + if (isMounted) { + setValue({ + resources: resourcesResponse.resources, + resourceTemplates: Array.isArray(resourcesResponse.resourceTemplates) ? resourcesResponse.resourceTemplates : [], + }) + addLog('info', `Listed ${resourcesResponse.resources.length} resources, ${Array.isArray(resourcesResponse.resourceTemplates) ? resourcesResponse.resourceTemplates.length : 0} resource templates.`) + } + } catch (err) { + addLog('error', `Error listing resources: ${err instanceof Error ? err.message : String(err)}`, err as Error) + throw err + } + } + + async function readResource(uri: string) { + if (stateRef !== 'ready' || !client) { + throw new Error(`MCP client is not ready (current state: ${current.state}). Cannot read resource "${uri}".`) + } + addLog('info', `Reading resource: ${uri}`) + try { + const result = await client.request({ method: 'resources/read', params: { uri } }, ReadResourceResultSchema) + addLog('info', `Resource "${uri}" read successfully`) + return result + } catch (err) { + addLog('error', `Error reading resource "${uri}": ${err instanceof Error ? err.message : String(err)}`, err as Error) + throw err + } + } + + async function listPrompts() { + if (stateRef !== 'ready' || !client) { + throw new Error(`MCP client is not ready (current state: ${current.state}). Cannot list prompts.`) + } + addLog('info', 'Listing prompts...') + try { + const promptsResponse = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema) + if (isMounted) { + setValue({ prompts: promptsResponse.prompts }) + addLog('info', `Listed ${promptsResponse.prompts.length} prompts.`) + } + } catch (err) { + addLog('error', `Error listing prompts: ${err instanceof Error ? err.message : String(err)}`, err as Error) + throw err + } + } + + async function getPrompt(name: string, args?: Record) { + if (stateRef !== 'ready' || !client) { + throw new Error(`MCP client is not ready (current state: ${current.state}). Cannot get prompt "${name}".`) + } + addLog('info', `Getting prompt: ${name}`, args) + try { + const result = await client.request({ method: 'prompts/get', params: { name, arguments: args } }, GetPromptResultSchema) + addLog('info', `Prompt "${name}" retrieved successfully`) + return result + } catch (err) { + addLog('error', `Error getting prompt "${name}": ${err instanceof Error ? err.message : String(err)}`, err as Error) + throw err + } + } + + function retry() { + if (stateRef === 'failed') { + addLog('info', 'Retry requested...') + connect() + } else { + addLog('warn', `Retry called but state is not 'failed' (state: ${stateRef}). Ignoring.`) + } + } + + async function authenticate() { + addLog('info', 'Manual authentication requested...') + const s = stateRef + if (s === 'failed') { + addLog('info', 'Attempting to reconnect and authenticate via retry...') + retry() + } else if (s === 'pending_auth') { + setState('authenticating') + clearAuthTimeout() + authTimeout = setTimeout(() => { + if (!isMounted) return + failConnection('Authentication timed out. Please try again.') + }, AUTH_TIMEOUT) + try { + assert(authProvider, 'Auth Provider not available for manual auth') + const authResult = await auth(authProvider, { serverUrl }) + if (!isMounted) return + if (authResult === 'AUTHORIZED') { + addLog('info', 'Manual authentication successful. Re-attempting connection...') + clearAuthTimeout() + connecting = false + connect() + } else if (authResult === 'REDIRECT') { + addLog('info', 'Redirecting for manual authentication. Waiting for callback...') + } + } catch (authError) { + if (!isMounted) return + clearAuthTimeout() + failConnection(`Manual authentication failed: ${authError instanceof Error ? authError.message : String(authError)}`, + authError instanceof Error ? authError : undefined) + } + } else if (s === 'authenticating') { + addLog('warn', 'Already attempting authentication.') + } else if (s === 'ready') { + addLog('info', 'Already authenticated and connected.') + } else { + addLog('warn', `Authentication requested in state ${s}. Will attempt connect -> auth.`) + connect() + } + } + + function clearStorage() { + if (authProvider) { + const count = authProvider.clearStorage() + addLog('info', `Cleared ${count} item(s) from localStorage for ${serverUrl}.`) + setValue({ authUrl: undefined }) + doDisconnect() + } else { + addLog('warn', 'Auth provider not initialized, cannot clear storage.') + } + } + + // Store start/stop lifecycle — start when there is at least one subscriber + let set: (v: UseMcpResult) => void + const store = readable(initialValue, (start) => { + set = start + isMounted = true + autoReconnectRef = autoReconnect + addLog('debug', 'createMcp mounted, initiating connection.') + connectAttempt = 0 + + // Initialize/refresh provider on mount/options change + if (isBrowser()) { + if (!authProvider || authProvider.serverUrl !== serverUrl) { + authProvider = new BrowserOAuthClientProvider(serverUrl, { + storageKeyPrefix, + clientName, + clientUri, + callbackUrl, + preventAutoAuth, + onPopupWindow, + }) + addLog('debug', 'BrowserOAuthClientProvider initialized/updated on mount.') + } + // Auth callback listener + const messageHandler = (event: MessageEvent) => { + if (event.origin !== window.location.origin && !allowedOrigins.includes(event.origin)) return + const t = (event as any)?.data?.type + if (t === 'mcp_auth_callback' || t === 'mcp:oauthCallback') { + addLog('info', 'Received auth callback message.', event.data) + clearAuthTimeout() + if (event.data.success) { + addLog('info', 'Authentication successful via popup. Reconnecting client...') + connecting = false + connect() + } else { + failConnection(`Authentication failed in callback: ${event.data.error || 'Unknown reason.'}`) + } + } + } + window.addEventListener('message', messageHandler) + + // Kick off connection + connect() + + return () => { + window.removeEventListener('message', messageHandler) + isMounted = false + clearAuthTimeout() + if (autoRetryTimer) { clearTimeout(autoRetryTimer); autoRetryTimer = null } + void doDisconnect(true) + } + } + + // SSR: no lifecycle, just noop stop + return () => { + isMounted = false + } + }) as Readable & any + + // Attach methods to the store object + Object.assign(store, { + callTool, + listResources, + readResource, + listPrompts, + getPrompt, + retry, + disconnect: () => { void doDisconnect() }, + authenticate: () => { void authenticate() }, + clearStorage, + }) + + // Also attach methods to the current snapshot so $mcp.callTool works + Object.assign(current, { + callTool, + listResources, + readResource, + listPrompts, + getPrompt, + retry, + disconnect: () => { void doDisconnect() }, + authenticate: () => { void authenticate() }, + clearStorage, + }) + + return store +} + +function isBrowser() { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} diff --git a/src/svelte/index.ts b/src/svelte/index.ts new file mode 100644 index 0000000..55f5e2c --- /dev/null +++ b/src/svelte/index.ts @@ -0,0 +1,11 @@ +/** + * Entry point for the use-mcp Svelte integration. + * Exposes a Svelte store factory mirroring the React hook contract. + */ + +export { createMcp } from './createMcp.js' +export type { UseMcpOptions, UseMcpResult } from '../react/types.js' +export { onMcpAuthorization } from '../auth/callback.js' + +// Re-export core types for convenience +export type { Tool, Resource, ResourceTemplate, Prompt } from '@modelcontextprotocol/sdk/types.js'