diff --git a/.github/workflows/mcp-client-publish.yml b/.github/workflows/mcp-client-publish.yml new file mode 100644 index 0000000000..c33b5d0686 --- /dev/null +++ b/.github/workflows/mcp-client-publish.yml @@ -0,0 +1,72 @@ +name: MCP Client - Version and Release + +on: + workflow_dispatch: + inputs: + newversion: + type: choice + description: "Semantic Version Bump Type" + default: patch + options: + - patch + - minor + - major + +concurrency: + group: "push-to-main" + +defaults: + run: + working-directory: packages/mcp-client + +jobs: + version_and_release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + token: ${{ secrets.BOT_ACCESS_TOKEN }} + - run: npm install -g corepack@latest && corepack enable + - uses: actions/setup-node@v3 + with: + node-version: "20" + cache: "pnpm" + cache-dependency-path: | + packages/mcp-client/pnpm-lock.yaml + packages/doc-internal/pnpm-lock.yaml + registry-url: "https://registry.npmjs.org" + - run: pnpm install + - run: git config --global user.name machineuser + - run: git config --global user.email infra+machineuser@huggingface.co + - run: | + PACKAGE_VERSION=$(node -p "require('./package.json').version") + BUMPED_VERSION=$(node -p "require('semver').inc('$PACKAGE_VERSION', '${{ github.event.inputs.newversion }}')") + # Update package.json with the new version + node -e "const fs = require('fs'); const package = JSON.parse(fs.readFileSync('./package.json')); package.version = '$BUMPED_VERSION'; fs.writeFileSync('./package.json', JSON.stringify(package, null, '\t') + '\n');" + pnpm --filter doc-internal run fix-cdn-versions + git add ../.. + git commit -m "🔖 @huggingface/mcp-client $BUMPED_VERSION" + git tag "mcp-client-v$BUMPED_VERSION" + + # Add checks for dependencies if needed, similar to hub-publish.yml + - name: "Check Deps are published before publishing this package" + run: pnpm -w check-deps inference && pnpm -w check-deps tasks + + - run: pnpm publish --no-git-checks . + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - run: git pull --rebase && git push --follow-tags + # hack - reuse actions/setup-node@v3 just to set a new registry + - uses: actions/setup-node@v3 + with: + node-version: "20" + registry-url: "https://npm.pkg.github.com" + # Disable for now, until github supports PATs for writing github packages (https://github.com/github/roadmap/issues/558) + # - run: pnpm publish --no-git-checks . + # env: + # NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: "Update Doc" + uses: peter-evans/repository-dispatch@v2 + with: + event-type: doc-build + token: ${{ secrets.BOT_ACCESS_TOKEN }} diff --git a/docs/_toctree.yml b/docs/_toctree.yml index 06876a026f..7b8008de96 100644 --- a/docs/_toctree.yml +++ b/docs/_toctree.yml @@ -14,6 +14,11 @@ title: Interact with the Hub - local: hub/modules title: API Reference +- title: "@huggingface/mcp-client" + isExpanded: true + sections: + - local: mcp-client/README + title: Simple MCP Client and smol Agent built on top of Inference Client - title: "@huggingface/agent" isExpanded: true sections: diff --git a/package.json b/package.json index 687477c39e..f184f5391d 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,9 @@ "check-deps": "tsx scripts/check-deps.ts" }, "devDependencies": { + "@types/node": "^22.14.1", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", - "@types/node": "^18.16.1", "@vitest/browser": "^0.34.6", "eslint": "^8.57.0", "eslint-config-prettier": "^9.0.0", diff --git a/packages/doc-internal/package.json b/packages/doc-internal/package.json index e8fa9ad093..22d0cdfe4b 100644 --- a/packages/doc-internal/package.json +++ b/packages/doc-internal/package.json @@ -5,7 +5,7 @@ "description": "Package to generate doc for other @huggingface packages", "private": true, "scripts": { - "start": "pnpm run fix-cdn-versions && pnpm run doc-hub && pnpm run doc-inference && pnpm run doc-agents && pnpm run doc-space-header && pnpm run doc-gguf && cp ../../README.md ../../docs/index.md && pnpm run update-toc && pnpm run fix-md-links && pnpm run fix-md-headinghashlinks", + "start": "pnpm run fix-cdn-versions && pnpm run doc-hub && pnpm run doc-inference && pnpm run doc-agents && pnpm run doc-space-header && pnpm run doc-gguf && pnpm run doc-mcp-client && cp ../../README.md ../../docs/index.md && pnpm run update-toc && pnpm run fix-md-links && pnpm run fix-md-headinghashlinks", "lint": "eslint --quiet --fix --ext .cjs,.ts .", "lint:check": "eslint --ext .cjs,.ts .", "format": "prettier --write .", @@ -14,6 +14,7 @@ "doc-inference": "typedoc --tsconfig ../inference/tsconfig.json --githubPages false --plugin typedoc-plugin-markdown --out ../../docs/inference --hideBreadcrumbs --hideInPageTOC --sourceLinkTemplate https://github.com/huggingface/huggingface.js/blob/main/{path}#L{line} ../inference/src/index.ts", "doc-agents": "typedoc --tsconfig ../agents/tsconfig.json --githubPages false --plugin typedoc-plugin-markdown --out ../../docs/agents --hideBreadcrumbs --hideInPageTOC --sourceLinkTemplate https://github.com/huggingface/huggingface.js/blob/main/{path}#L{line} ../agents/src/index.ts", "doc-gguf": "mkdir -p ../../docs/gguf && cp ../../packages/gguf/README.md ../../docs/gguf/README.md", + "doc-mcp-client": "mkdir -p ../../docs/mcp-client && cp ../../packages/mcp-client/README.md ../../docs/mcp-client/README.md", "doc-space-header": "mkdir -p ../../docs/space-header && cp ../../packages/space-header/README.md ../../docs/space-header/README.md", "update-toc": "tsx update-toc.ts", "fix-cdn-versions": "tsx fix-cdn-versions.ts", diff --git a/packages/mcp-client/.eslintignore b/packages/mcp-client/.eslintignore new file mode 100644 index 0000000000..53c37a1660 --- /dev/null +++ b/packages/mcp-client/.eslintignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/packages/mcp-client/.prettierignore b/packages/mcp-client/.prettierignore new file mode 100644 index 0000000000..cac0c69496 --- /dev/null +++ b/packages/mcp-client/.prettierignore @@ -0,0 +1,4 @@ +pnpm-lock.yaml +# In order to avoid code samples to have tabs, they don't display well on npm +README.md +dist \ No newline at end of file diff --git a/packages/mcp-client/README.md b/packages/mcp-client/README.md new file mode 100644 index 0000000000..1efbe1a81d --- /dev/null +++ b/packages/mcp-client/README.md @@ -0,0 +1,72 @@ +# @huggingface/mcp-client + +Client for the Model Context Protocol (MCP). + +This package provides a client implementation for interacting with MCP servers, built on top of our InferenceClient, `@huggingface/inference`. + +It includes an example CLI smol Agent that can leverage MCP tools. + +## Installation + +This package is part of the Hugging Face JS monorepo. To install dependencies for all packages, run from the root of the repository: + +```bash +pnpm install +``` + +## Usage (CLI Agent) + +The package includes a command-line interface (CLI) agent that demonstrates how to use the MCP client. + +### Prerequisites + +* **Hugging Face API Token:** You need a Hugging Face API token with appropriate permissions. Set it as an environment variable: + ```bash + export HF_TOKEN="hf_..." + ``` + +### Running the Agent + +Navigate to the package directory and run the agent script: + +```bash +cd packages/mcp-client +pnpm agent +``` + +Alternatively, run from the root of the monorepo: + +```bash +pnpm --filter @huggingface/mcp-client agent +``` + +The agent will load available MCP tools (by default, connecting to a filesystem server for your Desktop and a Playwright server) and prompt you for input (`>`). + +### Configuration (Environment Variables) + +* `HF_TOKEN` (Required): Your Hugging Face API token. +* `MODEL_ID` (Optional): The model ID to use for the agent's inference. Defaults to `Qwen/Qwen2.5-72B-Instruct`. +* `PROVIDER` (Optional): The inference provider. Defaults to `together`. See `@huggingface/inference` for available providers. +* `EXPERIMENTAL_HF_MCP_SERVER` (Optional): Set to `true` to enable connection to an experimental Hugging Face MCP server (requires separate setup). + +Example with custom model: + +```bash +export HF_TOKEN="hf_..." +export MODEL_ID="mistralai/Mixtral-8x7B-Instruct-v0.1" +pnpm agent +``` + +## Development + +Common development tasks can be run using pnpm scripts: + +* `pnpm build`: Build the package. +* `pnpm lint`: Lint and fix code style. +* `pnpm format`: Format code using Prettier. +* `pnpm test`: Run tests using Vitest. +* `pnpm check`: Type-check the code using TypeScript. + +## License + +MIT diff --git a/packages/mcp-client/cli.ts b/packages/mcp-client/cli.ts new file mode 100644 index 0000000000..740fad3bf4 --- /dev/null +++ b/packages/mcp-client/cli.ts @@ -0,0 +1,128 @@ +import * as readline from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { Agent } from "./src"; +import type { StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { ANSI } from "./src/utils"; +import type { InferenceProvider } from "@huggingface/inference"; + +const MODEL_ID = process.env.MODEL_ID ?? "Qwen/Qwen2.5-72B-Instruct"; +const PROVIDER = (process.env.PROVIDER as InferenceProvider) ?? "nebius"; + +const SERVERS: StdioServerParameters[] = [ + { + // Filesystem "official" mcp-server with access to your Desktop + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", join(homedir(), "Desktop")], + }, + { + // Playwright MCP + command: "npx", + args: ["@playwright/mcp@latest"], + }, +]; + +if (process.env.EXPERIMENTAL_HF_MCP_SERVER) { + SERVERS.push({ + // Early version of a HF-MCP server + // you can download it from gist.github.com/julien-c/0500ba922e1b38f2dc30447fb81f7dc6 + command: "node", + args: ["--disable-warning=ExperimentalWarning", join(homedir(), "Desktop/hf-mcp/index.ts")], + env: { + HF_TOKEN: process.env.HF_TOKEN ?? "", + }, + }); +} + +async function main() { + if (!process.env.HF_TOKEN) { + console.error(`a valid HF_TOKEN must be present in the env`); + process.exit(1); + } + + const agent = new Agent({ + provider: PROVIDER, + model: MODEL_ID, + apiKey: process.env.HF_TOKEN, + servers: SERVERS, + }); + + const rl = readline.createInterface({ input: stdin, output: stdout }); + let abortController = new AbortController(); + let waitingForInput = false; + async function waitForInput() { + waitingForInput = true; + const input = await rl.question("> "); + waitingForInput = false; + return input; + } + rl.on("SIGINT", async () => { + if (waitingForInput) { + // close the whole process + await agent.cleanup(); + stdout.write("\n"); + rl.close(); + } else { + // otherwise, it means a request is underway + abortController.abort(); + abortController = new AbortController(); + stdout.write("\n"); + stdout.write(ANSI.GRAY); + stdout.write("Ctrl+C a second time to exit"); + stdout.write(ANSI.RESET); + stdout.write("\n"); + } + }); + process.on("uncaughtException", (err) => { + stdout.write("\n"); + rl.close(); + throw err; + }); + + await agent.loadTools(); + + stdout.write(ANSI.BLUE); + stdout.write(`Agent loaded with ${agent.availableTools.length} tools:\n`); + stdout.write(agent.availableTools.map((t) => `- ${t.function.name}`).join("\n")); + stdout.write(ANSI.RESET); + stdout.write("\n"); + + while (true) { + const input = await waitForInput(); + for await (const chunk of agent.run(input, { abortSignal: abortController.signal })) { + if ("choices" in chunk) { + const delta = chunk.choices[0]?.delta; + if (delta.content) { + stdout.write(delta.content); + } + if (delta.tool_calls) { + stdout.write(ANSI.GRAY); + for (const deltaToolCall of delta.tool_calls) { + if (deltaToolCall.id) { + stdout.write(`\n`); + } + if (deltaToolCall.function.name) { + stdout.write(deltaToolCall.function.name + " "); + } + if (deltaToolCall.function.arguments) { + stdout.write(deltaToolCall.function.arguments); + } + } + stdout.write(ANSI.RESET); + } + } else { + /// Tool call info + stdout.write("\n\n"); + stdout.write(ANSI.GREEN); + stdout.write(`Tool[${chunk.name}] ${chunk.tool_call_id}\n`); + stdout.write(chunk.content); + stdout.write(ANSI.RESET); + stdout.write("\n\n"); + } + } + stdout.write("\n"); + } +} + +main(); diff --git a/packages/mcp-client/package.json b/packages/mcp-client/package.json new file mode 100644 index 0000000000..94f1d51f4b --- /dev/null +++ b/packages/mcp-client/package.json @@ -0,0 +1,57 @@ +{ + "name": "@huggingface/mcp-client", + "packageManager": "pnpm@8.10.5", + "version": "0.0.1", + "description": "Client for the Model Context Protocol", + "repository": "https://github.com/huggingface/huggingface.js.git", + "publishConfig": { + "access": "public" + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "import": "./dist/index.mjs" + } + }, + "engines": { + "node": ">=18" + }, + "source": "index.ts", + "scripts": { + "lint": "eslint --quiet --fix --ext .cjs,.ts .", + "lint:check": "eslint --ext .cjs,.ts .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "prepublishOnly": "pnpm run build", + "build": "tsup src/index.ts --format cjs,esm --clean && tsc --emitDeclarationOnly --declaration", + "prepare": "pnpm run build", + "test": "vitest run", + "check": "tsc", + "agent": "tsx cli.ts" + }, + "files": [ + "src", + "dist", + "index.ts", + "tsconfig.json" + ], + "keywords": [ + "huggingface", + "model context protocol", + "mcp", + "client", + "hugging", + "face" + ], + "author": "Hugging Face", + "license": "MIT", + "dependencies": { + "@huggingface/inference": "workspace:^", + "@huggingface/tasks": "workspace:^", + "@modelcontextprotocol/sdk": "^1.9.0" + } +} diff --git a/packages/mcp-client/pnpm-lock.yaml b/packages/mcp-client/pnpm-lock.yaml new file mode 100644 index 0000000000..ec6b111b0c --- /dev/null +++ b/packages/mcp-client/pnpm-lock.yaml @@ -0,0 +1,612 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + '@huggingface/inference': + specifier: workspace:^ + version: link:../inference + '@huggingface/tasks': + specifier: workspace:^ + version: link:../tasks + '@modelcontextprotocol/sdk': + specifier: ^1.9.0 + version: 1.10.1 + +packages: + + /@modelcontextprotocol/sdk@1.10.1: + resolution: {integrity: sha512-xNYdFdkJqEfIaTVP1gPKoEvluACHZsHZegIoICX8DM1o6Qf3G5u2BQJHmgd0n4YgRPqqK/u1ujQvrgAxxSJT9w==} + engines: {node: '>=18'} + dependencies: + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.6 + express: 5.1.0 + express-rate-limit: 7.5.0(express@5.1.0) + pkce-challenge: 5.0.0 + raw-body: 3.0.0 + zod: 3.24.3 + zod-to-json-schema: 3.24.5(zod@3.24.3) + transitivePeerDependencies: + - supports-color + dev: false + + /accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + dependencies: + mime-types: 3.0.1 + negotiator: 1.0.0 + dev: false + + /body-parser@2.2.0: + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + engines: {node: '>=18'} + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.0 + http-errors: 2.0.0 + iconv-lite: 0.6.3 + on-finished: 2.4.1 + qs: 6.14.0 + raw-body: 3.0.0 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + dev: false + + /bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + dev: false + + /call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + dev: false + + /call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + dev: false + + /content-disposition@1.0.0: + resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} + engines: {node: '>= 0.6'} + dependencies: + safe-buffer: 5.2.1 + dev: false + + /content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + dev: false + + /cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + dev: false + + /cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + dev: false + + /cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + dev: false + + /cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: false + + /debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + dev: false + + /depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dev: false + + /dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + dev: false + + /ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + dev: false + + /encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + dev: false + + /es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + dev: false + + /es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + dev: false + + /es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + dev: false + + /escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + dev: false + + /etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + dev: false + + /eventsource-parser@3.0.1: + resolution: {integrity: sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==} + engines: {node: '>=18.0.0'} + dev: false + + /eventsource@3.0.6: + resolution: {integrity: sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==} + engines: {node: '>=18.0.0'} + dependencies: + eventsource-parser: 3.0.1 + dev: false + + /express-rate-limit@7.5.0(express@5.1.0): + resolution: {integrity: sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==} + engines: {node: '>= 16'} + peerDependencies: + express: ^4.11 || 5 || ^5.0.0-beta.1 + dependencies: + express: 5.1.0 + dev: false + + /express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + engines: {node: '>= 18'} + dependencies: + accepts: 2.0.0 + body-parser: 2.2.0 + content-disposition: 1.0.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.0 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.1 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 + statuses: 2.0.1 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + dev: false + + /finalhandler@2.1.0: + resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} + engines: {node: '>= 0.8'} + dependencies: + debug: 4.4.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + dev: false + + /forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + dev: false + + /fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + dev: false + + /function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + dev: false + + /get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + dev: false + + /get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + dev: false + + /gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + dev: false + + /has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + dev: false + + /hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + dependencies: + function-bind: 1.1.2 + dev: false + + /http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + dev: false + + /iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: false + + /inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: false + + /ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + dev: false + + /is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + dev: false + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + dev: false + + /math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + dev: false + + /media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + dev: false + + /merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + dev: false + + /mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + dev: false + + /mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.54.0 + dev: false + + /ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + dev: false + + /negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + dev: false + + /object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + dev: false + + /object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + dev: false + + /on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + dependencies: + ee-first: 1.1.1 + dev: false + + /once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + dependencies: + wrappy: 1.0.2 + dev: false + + /parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + dev: false + + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: false + + /path-to-regexp@8.2.0: + resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} + engines: {node: '>=16'} + dev: false + + /pkce-challenge@5.0.0: + resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} + engines: {node: '>=16.20.0'} + dev: false + + /proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + dev: false + + /qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + dependencies: + side-channel: 1.1.0 + dev: false + + /range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + dev: false + + /raw-body@3.0.0: + resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} + engines: {node: '>= 0.8'} + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.6.3 + unpipe: 1.0.0 + dev: false + + /router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + dependencies: + debug: 4.4.0 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.2.0 + transitivePeerDependencies: + - supports-color + dev: false + + /safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + dev: false + + /safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + dev: false + + /send@1.2.0: + resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} + engines: {node: '>= 18'} + dependencies: + debug: 4.4.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.0 + mime-types: 3.0.1 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + dev: false + + /serve-static@2.2.0: + resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} + engines: {node: '>= 18'} + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.0 + transitivePeerDependencies: + - supports-color + dev: false + + /setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + dev: false + + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: false + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: false + + /side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + dev: false + + /side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + dev: false + + /side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + dev: false + + /side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + dev: false + + /statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + dev: false + + /toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + dev: false + + /type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.1 + dev: false + + /unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + dev: false + + /vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + dev: false + + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: false + + /wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + dev: false + + /zod-to-json-schema@3.24.5(zod@3.24.3): + resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} + peerDependencies: + zod: ^3.24.1 + dependencies: + zod: 3.24.3 + dev: false + + /zod@3.24.3: + resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==} + dev: false diff --git a/packages/mcp-client/src/Agent.ts b/packages/mcp-client/src/Agent.ts new file mode 100644 index 0000000000..0e58c92ae6 --- /dev/null +++ b/packages/mcp-client/src/Agent.ts @@ -0,0 +1,123 @@ +import type { InferenceProvider } from "@huggingface/inference"; +import type { ChatCompletionInputMessageTool } from "./McpClient"; +import { McpClient } from "./McpClient"; +import type { ChatCompletionInputMessage, ChatCompletionStreamOutput } from "@huggingface/tasks"; +import type { ChatCompletionInputTool } from "@huggingface/tasks/src/tasks/chat-completion/inference"; +import type { StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio"; +import { debug } from "./utils"; + +const DEFAULT_SYSTEM_PROMPT = ` +You are an agent - please keep going until the user’s query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved, or if you need more info from the user to solve the problem. + +If you are not sure about anything pertaining to the user’s request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer. + +You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully. +`.trim(); + +/** + * Max number of tool calling + chat completion steps in response to a single user query. + */ +const MAX_NUM_TURNS = 10; + +const taskCompletionTool: ChatCompletionInputTool = { + type: "function", + function: { + name: "task_complete", + description: "Call this tool when the task given by the user is complete", + parameters: { + type: "object", + properties: {}, + }, + }, +}; +const askQuestionTool: ChatCompletionInputTool = { + type: "function", + function: { + name: "ask_question", + description: "Ask a question to the user to get more info required to solve or clarify their problem.", + parameters: { + type: "object", + properties: {}, + }, + }, +}; +const exitLoopTools = [taskCompletionTool, askQuestionTool]; + +export class Agent extends McpClient { + private readonly servers: StdioServerParameters[]; + protected messages: ChatCompletionInputMessage[]; + + constructor({ + provider, + model, + apiKey, + servers, + }: { + provider: InferenceProvider; + model: string; + apiKey: string; + servers: StdioServerParameters[]; + }) { + super({ provider, model, apiKey }); + this.servers = servers; + this.messages = [ + { + role: "system", + content: DEFAULT_SYSTEM_PROMPT, + }, + ]; + } + + async loadTools(): Promise { + return this.addMcpServers(this.servers); + } + + async *run( + input: string, + opts: { abortSignal?: AbortSignal } = {} + ): AsyncGenerator { + this.messages.push({ + role: "user", + content: input, + }); + + let numOfTurns = 0; + let nextTurnShouldCallTools = true; + while (true) { + try { + yield* this.processSingleTurnWithTools(this.messages, { + exitLoopTools, + exitIfFirstChunkNoTool: numOfTurns > 0 && nextTurnShouldCallTools, + abortSignal: opts.abortSignal, + }); + } catch (err) { + if (err instanceof Error && err.message === "AbortError") { + return; + } + throw err; + } + numOfTurns++; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const currentLast = this.messages.at(-1)!; + debug("current role", currentLast.role); + if ( + currentLast.role === "tool" && + currentLast.name && + exitLoopTools.map((t) => t.function.name).includes(currentLast.name) + ) { + return; + } + if (currentLast.role !== "tool" && numOfTurns > MAX_NUM_TURNS) { + return; + } + if (currentLast.role !== "tool" && nextTurnShouldCallTools) { + return; + } + if (currentLast.role === "tool") { + nextTurnShouldCallTools = false; + } else { + nextTurnShouldCallTools = true; + } + } + } +} diff --git a/packages/mcp-client/src/McpClient.ts b/packages/mcp-client/src/McpClient.ts new file mode 100644 index 0000000000..93f9511949 --- /dev/null +++ b/packages/mcp-client/src/McpClient.ts @@ -0,0 +1,172 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { InferenceClient } from "@huggingface/inference"; +import type { InferenceProvider } from "@huggingface/inference"; +import type { + ChatCompletionInputMessage, + ChatCompletionInputTool, + ChatCompletionStreamOutput, + ChatCompletionStreamOutputDeltaToolCall, +} from "@huggingface/tasks/src/tasks/chat-completion/inference"; +import { version as packageVersion } from "../package.json"; +import { debug } from "./utils"; + +type ToolName = string; + +export interface ChatCompletionInputMessageTool extends ChatCompletionInputMessage { + role: "tool"; + tool_call_id: string; + content: string; + name?: string; +} + +export class McpClient { + protected client: InferenceClient; + protected provider: string; + protected model: string; + private clients: Map = new Map(); + public readonly availableTools: ChatCompletionInputTool[] = []; + + constructor({ provider, model, apiKey }: { provider: InferenceProvider; model: string; apiKey: string }) { + this.client = new InferenceClient(apiKey); + this.provider = provider; + this.model = model; + } + + async addMcpServers(servers: StdioServerParameters[]): Promise { + await Promise.all(servers.map((s) => this.addMcpServer(s))); + } + + async addMcpServer(server: StdioServerParameters): Promise { + const transport = new StdioClientTransport({ + ...server, + env: { ...server.env, PATH: process.env.PATH ?? "" }, + }); + const mcp = new Client({ name: "@huggingface/mcp-client", version: packageVersion }); + await mcp.connect(transport); + + const toolsResult = await mcp.listTools(); + debug( + "Connected to server with tools:", + toolsResult.tools.map(({ name }) => name) + ); + + for (const tool of toolsResult.tools) { + this.clients.set(tool.name, mcp); + } + + this.availableTools.push( + ...toolsResult.tools.map((tool) => { + return { + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema, + }, + } satisfies ChatCompletionInputTool; + }) + ); + } + + async *processSingleTurnWithTools( + messages: ChatCompletionInputMessage[], + opts: { + exitLoopTools?: ChatCompletionInputTool[]; + exitIfFirstChunkNoTool?: boolean; + abortSignal?: AbortSignal; + } = {} + ): AsyncGenerator { + debug("start of single turn"); + + const stream = this.client.chatCompletionStream({ + provider: this.provider, + model: this.model, + messages, + tools: opts.exitLoopTools ? [...opts.exitLoopTools, ...this.availableTools] : this.availableTools, + tool_choice: "auto", + signal: opts.abortSignal, + }); + + const message = { + role: "unknown", + content: "", + } satisfies ChatCompletionInputMessage; + const finalToolCalls: Record = {}; + let numOfChunks = 0; + + for await (const chunk of stream) { + if (opts.abortSignal?.aborted) { + throw new Error("AbortError"); + } + yield chunk; + debug(chunk.choices[0]); + numOfChunks++; + const delta = chunk.choices[0]?.delta; + if (!delta) { + continue; + } + if (delta.role) { + message.role = delta.role; + } + if (delta.content) { + message.content += delta.content; + } + for (const toolCall of delta.tool_calls ?? []) { + // aggregating chunks into an encoded arguments JSON object + if (!finalToolCalls[toolCall.index]) { + finalToolCalls[toolCall.index] = toolCall; + } + if (finalToolCalls[toolCall.index].function.arguments === undefined) { + finalToolCalls[toolCall.index].function.arguments = ""; + } + if (toolCall.function.arguments) { + finalToolCalls[toolCall.index].function.arguments += toolCall.function.arguments; + } + } + if (opts.exitIfFirstChunkNoTool && numOfChunks <= 2 && Object.keys(finalToolCalls).length === 0) { + /// If no tool is present in chunk number 1 or 2, exit. + return; + } + } + + messages.push(message); + + for (const toolCall of Object.values(finalToolCalls)) { + const toolName = toolCall.function.name ?? "unknown"; + /// TODO(Fix upstream type so this is always a string)^ + const toolArgs = toolCall.function.arguments === "" ? {} : JSON.parse(toolCall.function.arguments); + + const toolMessage: ChatCompletionInputMessageTool = { + role: "tool", + tool_call_id: toolCall.id, + content: "", + name: toolName, + }; + if (opts.exitLoopTools?.map((t) => t.function.name).includes(toolName)) { + messages.push(toolMessage); + return yield toolMessage; + } + /// Get the appropriate session for this tool + const client = this.clients.get(toolName); + if (client) { + const result = await client.callTool({ name: toolName, arguments: toolArgs, signal: opts.abortSignal }); + toolMessage.content = (result.content as Array<{ text: string }>)[0].text; + } else { + toolMessage.content = `Error: No session found for tool: ${toolName}`; + } + messages.push(toolMessage); + yield toolMessage; + } + } + + async cleanup(): Promise { + const clients = new Set(this.clients.values()); + await Promise.all([...clients].map((client) => client.close())); + } + + async [Symbol.dispose](): Promise { + return this.cleanup(); + } +} diff --git a/packages/mcp-client/src/index.ts b/packages/mcp-client/src/index.ts new file mode 100644 index 0000000000..352be6f15c --- /dev/null +++ b/packages/mcp-client/src/index.ts @@ -0,0 +1,2 @@ +export * from "./McpClient"; +export * from "./Agent"; diff --git a/packages/mcp-client/src/utils.ts b/packages/mcp-client/src/utils.ts new file mode 100644 index 0000000000..2440cfe8de --- /dev/null +++ b/packages/mcp-client/src/utils.ts @@ -0,0 +1,15 @@ +import { inspect } from "util"; + +export function debug(...args: unknown[]): void { + if (process.env.DEBUG) { + console.debug(inspect(args, { depth: Infinity, colors: true })); + } +} + +export const ANSI = { + BLUE: "\x1b[34m", + GRAY: "\x1b[90m", + GREEN: "\x1b[32m", + RED: "\x1b[31m", + RESET: "\x1b[0m", +}; diff --git a/packages/mcp-client/test/McpClient.spec.ts b/packages/mcp-client/test/McpClient.spec.ts new file mode 100644 index 0000000000..d86850f271 --- /dev/null +++ b/packages/mcp-client/test/McpClient.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { McpClient } from "../src"; + +if (!process.env.HF_TOKEN) { + console.warn("Set HF_TOKEN in the env to run the tests for better rate limits"); +} + +describe("McpClient", () => { + it("You can create a mcp client", async () => { + const client = new McpClient({ + provider: "together", + model: "Qwen/Qwen2.5-72B-Instruct", + apiKey: process.env.HF_TOKEN ?? "", + }); + expect(client).toBeDefined(); + expect(client.availableTools.length).toBe(0); + }); +}); diff --git a/packages/mcp-client/tsconfig.json b/packages/mcp-client/tsconfig.json new file mode 100644 index 0000000000..8274efe5ca --- /dev/null +++ b/packages/mcp-client/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "lib": ["ES2022", "DOM"], + "module": "CommonJS", + "moduleResolution": "node", + "target": "ES2022", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "skipLibCheck": true, + "noImplicitOverride": true, + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src", "test"], + "exclude": ["dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53ea212e62..af150cb8ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: devDependencies: '@types/node': - specifier: ^18.16.1 - version: 18.19.61 + specifier: ^22.14.1 + version: 22.14.1 '@typescript-eslint/eslint-plugin': specifier: ^7.2.0 version: 7.2.0(@typescript-eslint/parser@7.2.0)(eslint@8.57.0)(typescript@5.4.2) @@ -55,7 +55,7 @@ devDependencies: version: 5.4.2 vite: specifier: ^5.0.2 - version: 5.0.2(@types/node@18.19.61) + version: 5.0.2(@types/node@22.14.1) vitest: specifier: ^0.34.6 version: 0.34.6(@vitest/browser@0.34.6)(webdriverio@8.6.7) @@ -1205,6 +1205,12 @@ packages: undici-types: 5.26.5 dev: true + /@types/node@22.14.1: + resolution: {integrity: sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==} + dependencies: + undici-types: 6.21.0 + dev: true + /@types/normalize-package-data@2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} dev: true @@ -5026,6 +5032,10 @@ packages: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} dev: true + /undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + dev: true + /uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} dependencies: @@ -5106,6 +5116,42 @@ packages: fsevents: 2.3.3 dev: true + /vite@5.0.2(@types/node@22.14.1): + resolution: {integrity: sha512-6CCq1CAJCNM1ya2ZZA7+jS2KgnhbzvxakmlIjN24cF/PXhRMzpM/z8QgsVJA/Dm5fWUWnVEsmtBoMhmerPxT0g==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 22.14.1 + esbuild: 0.19.8 + postcss: 8.4.31 + rollup: 4.6.0 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /vitest@0.34.6(@vitest/browser@0.34.6)(webdriverio@8.6.7): resolution: {integrity: sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==} engines: {node: '>=v14.18.0'} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1c4ae9a79d..94ca7a9323 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,3 +12,4 @@ packages: - "packages/jinja" - "packages/space-header" - "packages/ollama-utils" + - "packages/mcp-client"