├── .actor ├── ACTOR.md ├── Dockerfile ├── actor.json └── input_schema.json ├── .dockerignore ├── .editorconfig ├── .env.example ├── .github ├── scripts │ └── before-beta-release.cjs └── workflows │ ├── check.yaml │ ├── pre_release.yaml │ └── release.yaml ├── .gitignore ├── .npmignore ├── .nvmrc ├── CHANGELOG.md ├── Dockerfile ├── LICENSE.md ├── README.md ├── docs ├── actors-mcp-server.png └── claude-desktop.png ├── eslint.config.mjs ├── glama.json ├── package-lock.json ├── package.json ├── smithery.yaml ├── src ├── actor │ ├── README.md │ ├── const.ts │ ├── server.ts │ ├── types.ts │ └── utils.ts ├── apify-client.ts ├── const.ts ├── examples │ ├── clientSse.ts │ ├── clientStdio.ts │ ├── clientStdioChat.ts │ ├── clientStreamableHttp.ts │ └── client_sse.py ├── index.ts ├── input.ts ├── main.ts ├── mcp │ ├── actors.ts │ ├── client.ts │ ├── const.ts │ ├── proxy.ts │ ├── server.ts │ └── utils.ts ├── stdio.ts ├── tools │ ├── actor.ts │ ├── build.ts │ ├── dataset.ts │ ├── dataset_collection.ts │ ├── helpers.ts │ ├── index.ts │ ├── key_value_store.ts │ ├── key_value_store_collection.ts │ ├── run.ts │ ├── run_collection.ts │ ├── store_collection.ts │ └── utils.ts ├── tsconfig.json └── types.ts ├── tests ├── README.md ├── helpers.ts ├── integration │ ├── actor.server-sse.test.ts │ ├── actor.server-streamable.test.ts │ ├── stdio.test.ts │ └── suite.ts └── unit │ ├── input.test.ts │ ├── mcp.utils.test.ts │ ├── tools.actor.test.ts │ └── tools.utils.test.ts ├── tsconfig.eslint.json ├── tsconfig.json └── vitest.config.ts /.actor/Dockerfile: -------------------------------------------------------------------------------- 1 | # Specify the base Docker image. You can read more about 2 | # the available images at https://docs.apify.com/sdk/js/docs/guides/docker-images 3 | # You can also use any other image from Docker Hub. 4 | FROM apify/actor-node:20 AS builder 5 | 6 | # Check preinstalled packages 7 | RUN npm ls crawlee apify puppeteer playwright 8 | 9 | # Copy just package.json and package-lock.json 10 | # to speed up the build using Docker layer cache. 11 | COPY package*.json ./ 12 | 13 | # Install all dependencies. Don't audit to speed up the installation. 14 | RUN npm install --include=dev --audit=false 15 | 16 | # Next, copy the source files using the user set 17 | # in the base image. 18 | COPY . ./ 19 | 20 | # Install all dependencies and build the project. 21 | # Don't audit to speed up the installation. 22 | RUN npm run build 23 | 24 | # Create final image 25 | FROM apify/actor-node:20 26 | 27 | # Check preinstalled packages 28 | RUN npm ls crawlee apify puppeteer playwright 29 | 30 | # Copy just package.json and package-lock.json 31 | # to speed up the build using Docker layer cache. 32 | COPY package*.json ./ 33 | 34 | # Install NPM packages, skip optional and development dependencies to 35 | # keep the image small. Avoid logging too much and print the dependency 36 | # tree for debugging 37 | RUN npm --quiet set progress=false \ 38 | && npm install --omit=dev --omit=optional \ 39 | && echo "Installed NPM packages:" \ 40 | && (npm list --omit=dev --all || true) \ 41 | && echo "Node.js version:" \ 42 | && node --version \ 43 | && echo "NPM version:" \ 44 | && npm --version \ 45 | && rm -r ~/.npm 46 | 47 | # Copy built JS files from builder image 48 | COPY --from=builder /usr/src/app/dist ./dist 49 | 50 | # Next, copy the remaining files and directories with the source code. 51 | # Since we do this after NPM install, quick build will be really fast 52 | # for most source file changes. 53 | COPY . ./ 54 | 55 | 56 | # Run the image. 57 | CMD npm run start:prod --silent 58 | -------------------------------------------------------------------------------- /.actor/actor.json: -------------------------------------------------------------------------------- 1 | { 2 | "actorSpecification": 1, 3 | "name": "apify-mcp-server", 4 | "title": "Model Context Protocol Server for Apify Actors", 5 | "description": "Implementation of a Model Context Protocol (MCP) Server for Apify Actors that enables AI applications (and AI agents) to interact with Apify Actors", 6 | "version": "0.1", 7 | "input": "./input_schema.json", 8 | "readme": "./ACTOR.md", 9 | "dockerfile": "./Dockerfile", 10 | "webServerMcpPath": "/sse" 11 | } 12 | -------------------------------------------------------------------------------- /.actor/input_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Apify MCP Server", 3 | "type": "object", 4 | "schemaVersion": 1, 5 | "properties": { 6 | "actors": { 7 | "title": "Actors to be exposed for an AI application (AI agent)", 8 | "type": "array", 9 | "description": "List Actors to be exposed to an AI application (AI agent) for communication via the MCP protocol. \n\n Ensure the Actor definitions fit within the LLM context by limiting the number of used Actors.", 10 | "editor": "stringList", 11 | "prefill": [ 12 | "apify/instagram-scraper", 13 | "apify/rag-web-browser", 14 | "lukaskrivka/google-maps-with-contact-details" 15 | ] 16 | }, 17 | "enableActorAutoLoading": { 18 | "title": "Enable automatic loading of Actors based on context and use-case (experimental, check if it supported by your client) (deprecated, use enableAddingActors instead)", 19 | "type": "boolean", 20 | "description": "When enabled, the server can dynamically add Actors as tools based on user requests and context. \n\nNote: MCP client must support notification on tool updates. To try it, you can use the [Tester MCP Client](https://apify.com/jiri.spilka/tester-mcp-client). This is an experimental feature and may require client-specific support.", 21 | "default": false, 22 | "editor": "hidden" 23 | }, 24 | "enableAddingActors": { 25 | "title": "Enable adding Actors based on context and use-case (experimental, check if it supported by your client)", 26 | "type": "boolean", 27 | "description": "When enabled, the server can dynamically add Actors as tools based on user requests and context. \n\nNote: MCP client must support notification on tool updates. To try it, you can use the [Tester MCP Client](https://apify.com/jiri.spilka/tester-mcp-client). This is an experimental feature and may require client-specific support.", 28 | "default": false 29 | }, 30 | "maxActorMemoryBytes": { 31 | "title": "Limit the maximum memory used by an Actor", 32 | "type": "integer", 33 | "description": "Limit the maximum memory used by an Actor in bytes. This is important setting for Free plan users to avoid exceeding the memory limit.", 34 | "prefill": 4096, 35 | "default": 4096 36 | }, 37 | "debugActor": { 38 | "title": "Debug Actor", 39 | "type": "string", 40 | "description": "Specify the name of the Actor that will be used for debugging in normal mode", 41 | "editor": "textfield", 42 | "prefill": "apify/rag-web-browser", 43 | "sectionCaption": "Debugging settings (normal mode)" 44 | }, 45 | "debugActorInput": { 46 | "title": "Debug Actor input", 47 | "type": "object", 48 | "description": "Specify the input for the Actor that will be used for debugging in normal mode", 49 | "editor": "json", 50 | "prefill": { 51 | "query": "hello world" 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # configurations 2 | .idea 3 | 4 | # crawlee and apify storage folders 5 | apify_storage 6 | crawlee_storage 7 | storage 8 | 9 | # installed files 10 | node_modules 11 | 12 | # git folder 13 | .git 14 | 15 | # data 16 | data 17 | src/storage 18 | dist 19 | .env 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APIFY_TOKEN= 2 | # ANTHROPIC_API_KEY is only required when you want to run examples/clientStdioChat.js 3 | ANTHROPIC_API_KEY= 4 | -------------------------------------------------------------------------------- /.github/scripts/before-beta-release.cjs: -------------------------------------------------------------------------------- 1 | const { execSync } = require('node:child_process'); 2 | const fs = require('node:fs'); 3 | const path = require('node:path'); 4 | 5 | const PKG_JSON_PATH = path.join(__dirname, '..', '..', 'package.json'); 6 | 7 | const pkgJson = require(PKG_JSON_PATH); // eslint-disable-line import/no-dynamic-require 8 | 9 | const PACKAGE_NAME = pkgJson.name; 10 | const VERSION = pkgJson.version; 11 | 12 | const nextVersion = getNextVersion(VERSION); 13 | console.log(`before-deploy: Setting version to ${nextVersion}`); // eslint-disable-line no-console 14 | pkgJson.version = nextVersion; 15 | 16 | fs.writeFileSync(PKG_JSON_PATH, `${JSON.stringify(pkgJson, null, 2)}\n`); 17 | 18 | function getNextVersion(version) { 19 | const versionString = execSync(`npm show ${PACKAGE_NAME} versions --json`, { encoding: 'utf8' }); 20 | const versions = JSON.parse(versionString); 21 | 22 | if (versions.some((v) => v === VERSION)) { 23 | console.error(`before-deploy: A release with version ${VERSION} already exists. Please increment version accordingly.`); // eslint-disable-line no-console 24 | process.exit(1); 25 | } 26 | 27 | const prereleaseNumbers = versions 28 | .filter((v) => (v.startsWith(VERSION) && v.includes('-'))) 29 | .map((v) => Number(v.match(/\.(\d+)$/)[1])); 30 | const lastPrereleaseNumber = Math.max(-1, ...prereleaseNumbers); 31 | return `${version}-beta.${lastPrereleaseNumber + 1}`; 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | # This workflow runs for every pull request to lint and test the proposed changes. 2 | 3 | name: Check 4 | 5 | on: 6 | pull_request: 7 | 8 | # Push to master will trigger code checks 9 | push: 10 | branches: 11 | - master 12 | tags-ignore: 13 | - "**" # Ignore all tags to prevent duplicate builds when tags are pushed. 14 | 15 | jobs: 16 | lint_and_test: 17 | name: Code checks 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Use Node.js 22 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 22 26 | cache: 'npm' 27 | cache-dependency-path: 'package-lock.json' 28 | - name: Install Dependencies 29 | run: npm ci 30 | 31 | - name: Lint 32 | run: npm run lint 33 | 34 | - name: Build 35 | run: npm run build 36 | 37 | - name: Test 38 | run: npm run test 39 | 40 | - name: Type checks 41 | run: npm run type-check 42 | -------------------------------------------------------------------------------- /.github/workflows/pre_release.yaml: -------------------------------------------------------------------------------- 1 | name: Create a pre-release 2 | 3 | on: 4 | workflow_dispatch: 5 | # Push to master will deploy a beta version 6 | push: 7 | branches: 8 | - master 9 | tags-ignore: 10 | - "**" # Ignore all tags to prevent duplicate builds when tags are pushed. 11 | 12 | concurrency: 13 | group: release 14 | cancel-in-progress: false 15 | 16 | jobs: 17 | release_metadata: 18 | if: "!startsWith(github.event.head_commit.message, 'docs') && !startsWith(github.event.head_commit.message, 'ci') && startsWith(github.repository, 'apify/')" 19 | name: Prepare release metadata 20 | runs-on: ubuntu-latest 21 | outputs: 22 | version_number: ${{ steps.release_metadata.outputs.version_number }} 23 | changelog: ${{ steps.release_metadata.outputs.changelog }} 24 | steps: 25 | - uses: apify/workflows/git-cliff-release@main 26 | name: Prepare release metadata 27 | id: release_metadata 28 | with: 29 | release_type: prerelease 30 | existing_changelog_path: CHANGELOG.md 31 | 32 | wait_for_checks: 33 | name: Wait for code checks to pass 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: lewagon/wait-on-check-action@v1.3.4 37 | with: 38 | ref: ${{ github.ref }} 39 | repo-token: ${{ secrets.GITHUB_TOKEN }} 40 | check-regexp: (Code checks) 41 | wait-interval: 5 42 | 43 | update_changelog: 44 | needs: [ release_metadata, wait_for_checks ] 45 | name: Update changelog 46 | runs-on: ubuntu-latest 47 | outputs: 48 | changelog_commitish: ${{ steps.commit.outputs.commit_long_sha || github.sha }} 49 | 50 | steps: 51 | - name: Checkout repository 52 | uses: actions/checkout@v4 53 | with: 54 | token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }} 55 | 56 | - name: Use Node.js 22 57 | uses: actions/setup-node@v4 58 | with: 59 | node-version: 22 60 | 61 | - name: Update package version in package.json 62 | run: npm version --no-git-tag-version --allow-same-version ${{ needs.release_metadata.outputs.version_number }} 63 | 64 | - name: Update CHANGELOG.md 65 | uses: DamianReeves/write-file-action@master 66 | with: 67 | path: CHANGELOG.md 68 | write-mode: overwrite 69 | contents: ${{ needs.release_metadata.outputs.changelog }} 70 | 71 | - name: Commit changes 72 | id: commit 73 | uses: EndBug/add-and-commit@v9 74 | with: 75 | author_name: Apify Release Bot 76 | author_email: noreply@apify.com 77 | message: "chore(release): Update changelog and package version [skip ci]" 78 | 79 | publish_to_npm: 80 | name: Publish to NPM 81 | needs: [ release_metadata, wait_for_checks ] 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v4 85 | with: 86 | ref: ${{ needs.update_changelog.changelog_commitish }} 87 | - name: Use Node.js 22 88 | uses: actions/setup-node@v4 89 | with: 90 | node-version: 22 91 | cache: 'npm' 92 | cache-dependency-path: 'package-lock.json' 93 | - name: Install dependencies 94 | run: | 95 | echo "access=public" >> .npmrc 96 | echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc 97 | npm ci 98 | - # Check version consistency and increment pre-release version number for beta only. 99 | name: Bump pre-release version 100 | run: node ./.github/scripts/before-beta-release.cjs 101 | - name: Build module 102 | run: npm run build 103 | - name: Publish to NPM 104 | run: npm publish --tag beta 105 | 106 | env: 107 | NODE_AUTH_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_NPM_TOKEN }} 108 | NPM_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_NPM_TOKEN }} 109 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Create a release 2 | 3 | on: 4 | # Trigger a stable version release via GitHub's UI, with the ability to specify the type of release. 5 | workflow_dispatch: 6 | inputs: 7 | release_type: 8 | description: Release type 9 | required: true 10 | type: choice 11 | default: auto 12 | options: 13 | - auto 14 | - custom 15 | - patch 16 | - minor 17 | - major 18 | custom_version: 19 | description: The custom version to bump to (only for "custom" type) 20 | required: false 21 | type: string 22 | default: "" 23 | 24 | concurrency: 25 | group: release 26 | cancel-in-progress: false 27 | 28 | jobs: 29 | release_metadata: 30 | name: Prepare release metadata 31 | runs-on: ubuntu-latest 32 | outputs: 33 | version_number: ${{ steps.release_metadata.outputs.version_number }} 34 | tag_name: ${{ steps.release_metadata.outputs.tag_name }} 35 | changelog: ${{ steps.release_metadata.outputs.changelog }} 36 | release_notes: ${{ steps.release_metadata.outputs.release_notes }} 37 | steps: 38 | - uses: apify/workflows/git-cliff-release@main 39 | name: Prepare release metadata 40 | id: release_metadata 41 | with: 42 | release_type: ${{ inputs.release_type }} 43 | custom_version: ${{ inputs.custom_version }} 44 | existing_changelog_path: CHANGELOG.md 45 | 46 | update_changelog: 47 | needs: [ release_metadata ] 48 | name: Update changelog 49 | runs-on: ubuntu-latest 50 | outputs: 51 | changelog_commitish: ${{ steps.commit.outputs.commit_long_sha || github.sha }} 52 | 53 | steps: 54 | - name: Checkout repository 55 | uses: actions/checkout@v4 56 | with: 57 | token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }} 58 | 59 | - name: Use Node.js 22 60 | uses: actions/setup-node@v4 61 | with: 62 | node-version: 22 63 | 64 | - name: Update package version in package.json 65 | run: npm version --no-git-tag-version --allow-same-version ${{ needs.release_metadata.outputs.version_number }} 66 | 67 | - name: Update CHANGELOG.md 68 | uses: DamianReeves/write-file-action@master 69 | with: 70 | path: CHANGELOG.md 71 | write-mode: overwrite 72 | contents: ${{ needs.release_metadata.outputs.changelog }} 73 | 74 | - name: Commit changes 75 | id: commit 76 | uses: EndBug/add-and-commit@v9 77 | with: 78 | author_name: Apify Release Bot 79 | author_email: noreply@apify.com 80 | message: "chore(release): Update changelog and package version [skip ci]" 81 | 82 | create_github_release: 83 | name: Create github release 84 | needs: [release_metadata, update_changelog] 85 | runs-on: ubuntu-latest 86 | env: 87 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 88 | steps: 89 | - name: Create release 90 | uses: softprops/action-gh-release@v2 91 | with: 92 | tag_name: ${{ needs.release_metadata.outputs.tag_name }} 93 | name: ${{ needs.release_metadata.outputs.version_number }} 94 | target_commitish: ${{ needs.update_changelog.outputs.changelog_commitish }} 95 | body: ${{ needs.release_metadata.outputs.release_notes }} 96 | 97 | publish_to_npm: 98 | name: Publish to NPM 99 | needs: [ update_changelog ] 100 | runs-on: ubuntu-latest 101 | steps: 102 | - uses: actions/checkout@v4 103 | with: 104 | ref: ${{ needs.update_changelog.changelog_commitish }} 105 | - name: Use Node.js 22 106 | uses: actions/setup-node@v4 107 | with: 108 | node-version: 22 109 | cache: 'npm' 110 | cache-dependency-path: 'package-lock.json' 111 | - name: Install dependencies 112 | run: | 113 | echo "access=public" >> .npmrc 114 | echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc 115 | npm ci 116 | - name: Build module 117 | run: npm run build 118 | - name: Publish to NPM 119 | run: npm publish --tag latest 120 | 121 | env: 122 | NODE_AUTH_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_NPM_TOKEN }} 123 | NPM_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_NPM_TOKEN }} 124 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file tells Git which files shouldn't be added to source control 2 | 3 | .idea 4 | .vscode 5 | storage 6 | apify_storage 7 | crawlee_storage 8 | node_modules 9 | dist 10 | tsconfig.tsbuildinfo 11 | storage/* 12 | !storage/key_value_stores 13 | storage/key_value_stores/* 14 | !storage/key_value_stores/default 15 | storage/key_value_stores/default/* 16 | !storage/key_value_stores/default/INPUT.json 17 | 18 | # Added by Apify CLI 19 | .venv 20 | .env 21 | .aider* 22 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # .npmignore 2 | # Exclude everything by default 3 | * 4 | 5 | # Include specific files and folders 6 | !dist/ 7 | !README.md 8 | !LICENSE 9 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.13.1 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | # Stage 1: Build the TypeScript project 3 | FROM node:18-alpine AS builder 4 | 5 | # Set working directory 6 | WORKDIR /app 7 | 8 | # Copy package files and install dependencies 9 | COPY package.json package-lock.json ./ 10 | RUN npm install 11 | 12 | # Copy source files 13 | COPY src ./src 14 | COPY tsconfig.json ./ 15 | 16 | # Build the project 17 | RUN npm run build 18 | 19 | # Stage 2: Set up the runtime environment 20 | FROM node:18-alpine 21 | 22 | # Set working directory 23 | WORKDIR /app 24 | 25 | # Copy only the necessary files from the build stage 26 | COPY --from=builder /app/dist ./dist 27 | COPY package.json package-lock.json ./ 28 | 29 | # Install production dependencies only 30 | RUN npm ci --omit=dev 31 | 32 | # Expose any necessary ports (example: 3000) 33 | EXPOSE 3000 34 | 35 | # Set the environment variable for the Apify token 36 | ENV APIFY_TOKEN= 37 | 38 | # Set the entry point for the container 39 | ENTRYPOINT ["node", "dist/main.js"] -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Apify Technologies s.r.o. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /docs/actors-mcp-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apify/actors-mcp-server/bc8369e2c5975c8683305efac678717e4987ffbc/docs/actors-mcp-server.png -------------------------------------------------------------------------------- /docs/claude-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apify/actors-mcp-server/bc8369e2c5975c8683305efac678717e4987ffbc/docs/claude-desktop.png -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import apifyTypeScriptConfig from '@apify/eslint-config/ts.js'; 2 | 3 | // eslint-disable-next-line import/no-default-export 4 | export default [ 5 | { ignores: ['**/dist'] }, // Ignores need to happen first 6 | ...apifyTypeScriptConfig, 7 | { 8 | languageOptions: { 9 | sourceType: 'module', 10 | 11 | parserOptions: { 12 | project: 'tsconfig.eslint.json', 13 | }, 14 | }, 15 | }, 16 | ]; 17 | -------------------------------------------------------------------------------- /glama.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://glama.ai/mcp/schemas/server.json", 3 | "maintainers": [ "jirispilka", "mq37" ] 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apify/actors-mcp-server", 3 | "version": "0.2.4", 4 | "type": "module", 5 | "description": "Model Context Protocol Server for Apify", 6 | "engines": { 7 | "node": ">=18.0.0" 8 | }, 9 | "main": "dist/index.js", 10 | "bin": { 11 | "actors-mcp-server": "./dist/stdio.js" 12 | }, 13 | "files": [ 14 | "dist", 15 | "LICENSE" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/apify/actors-mcp-server.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/apify/actors-mcp-server/issues" 23 | }, 24 | "homepage": "https://apify.com/apify/actors-mcp-server", 25 | "keywords": [ 26 | "apify", 27 | "mcp", 28 | "server", 29 | "actors", 30 | "model context protocol" 31 | ], 32 | "dependencies": { 33 | "@apify/datastructures": "^2.0.3", 34 | "@apify/log": "^2.5.16", 35 | "@modelcontextprotocol/sdk": "^1.11.5", 36 | "ajv": "^8.17.1", 37 | "apify": "^3.4.0", 38 | "apify-client": "^2.12.3", 39 | "express": "^4.21.2", 40 | "yargs": "^17.7.2", 41 | "zod": "^3.24.1", 42 | "zod-to-json-schema": "^3.24.1" 43 | }, 44 | "devDependencies": { 45 | "@anthropic-ai/sdk": "^0.33.1", 46 | "@anthropic-ai/tokenizer": "^0.0.4", 47 | "@apify/eslint-config": "^1.0.0", 48 | "@apify/tsconfig": "^0.1.0", 49 | "@types/express": "^4.0.0", 50 | "@types/yargs": "^17.0.33", 51 | "@types/yargs-parser": "^21.0.3", 52 | "dotenv": "^16.4.7", 53 | "eslint": "^9.19.0", 54 | "eventsource": "^3.0.2", 55 | "tsx": "^4.6.2", 56 | "typescript": "^5.3.3", 57 | "typescript-eslint": "^8.23.0", 58 | "vitest": "^3.0.8" 59 | }, 60 | "scripts": { 61 | "start": "npm run start:dev", 62 | "start:prod": "node dist/main.js", 63 | "start:dev": "tsx src/main.ts", 64 | "lint": "eslint .", 65 | "lint:fix": "eslint . --fix", 66 | "build": "tsc -b src", 67 | "build:watch": "tsc -b src -w", 68 | "type-check": "tsc --noEmit", 69 | "inspector": "npm run build && npx @modelcontextprotocol/inspector dist/stdio.js", 70 | "test": "npm run test:unit", 71 | "test:unit": "vitest run tests/unit", 72 | "test:integration": "npm run build && vitest run tests/integration", 73 | "clean": "tsc -b src --clean" 74 | }, 75 | "author": "Apify", 76 | "license": "MIT" 77 | } 78 | -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: 9 | - apifyToken 10 | properties: 11 | apifyToken: 12 | type: string 13 | description: The API token for accessing Apify's services. 14 | commandFunction: 15 | # A function that produces the CLI command to start the MCP on stdio. 16 | |- 17 | (config) => ({ command: 'node', args: ['dist/main.js'], env: { APIFY_TOKEN: config.apifyToken } }) -------------------------------------------------------------------------------- /src/actor/README.md: -------------------------------------------------------------------------------- 1 | # Actor 2 | 3 | Code related to Apify Actor called Actors-MCP-Server. 4 | This Actor will be deprecated in favor of Apify MCP Server, therefore we are keeping it separate from the main codebase. 5 | 6 | The only exception is the `src/main.ts` file that also belongs to the Actor. 7 | -------------------------------------------------------------------------------- /src/actor/const.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Constants for the Actor. 3 | */ 4 | export const HEADER_READINESS_PROBE = 'x-apify-container-server-readiness-probe'; 5 | 6 | export enum Routes { 7 | ROOT = '/', 8 | MCP = '/mcp', 9 | SSE = '/sse', 10 | MESSAGE = '/message', 11 | } 12 | 13 | export const getHelpMessage = (host: string) => `To interact with the server you can either: 14 | - send request to ${host}${Routes.MCP}?token=YOUR-APIFY-TOKEN and receive a response 15 | or 16 | - connect for Server-Sent Events (SSE) via GET request to: ${host}${Routes.SSE}?token=YOUR-APIFY-TOKEN 17 | - send messages via POST request to: ${host}${Routes.MESSAGE}?token=YOUR-APIFY-TOKEN 18 | (Include your message content in the request body.)`; 19 | -------------------------------------------------------------------------------- /src/actor/server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Express server implementation used for standby Actor mode. 3 | */ 4 | 5 | import { randomUUID } from 'node:crypto'; 6 | 7 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; 8 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; 9 | import type { Request, Response } from 'express'; 10 | import express from 'express'; 11 | 12 | import log from '@apify/log'; 13 | 14 | import { type ActorsMcpServer } from '../mcp/server.js'; 15 | import { parseInputParamsFromUrl, processParamsGetTools } from '../mcp/utils.js'; 16 | import { getHelpMessage, HEADER_READINESS_PROBE, Routes } from './const.js'; 17 | import { getActorRunData } from './utils.js'; 18 | 19 | /** 20 | * Helper function to load tools and actors based on input parameters 21 | * @param mcpServer The MCP server instance 22 | * @param url The request URL to parse parameters from 23 | * @param apifyToken The Apify token for authentication 24 | */ 25 | async function loadToolsAndActors(mcpServer: ActorsMcpServer, url: string, apifyToken: string): Promise { 26 | const input = parseInputParamsFromUrl(url); 27 | if (input.actors || input.enableAddingActors) { 28 | await mcpServer.loadToolsFromUrl(url, apifyToken); 29 | } 30 | if (!input.actors) { 31 | await mcpServer.loadDefaultActors(apifyToken); 32 | } 33 | } 34 | 35 | export function createExpressApp( 36 | host: string, 37 | mcpServer: ActorsMcpServer, 38 | ): express.Express { 39 | const app = express(); 40 | let transportSSE: SSEServerTransport; 41 | const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; 42 | 43 | function respondWithError(res: Response, error: unknown, logMessage: string, statusCode = 500) { 44 | log.error(`${logMessage}: ${error}`); 45 | if (!res.headersSent) { 46 | res.status(statusCode).json({ 47 | jsonrpc: '2.0', 48 | error: { 49 | code: statusCode === 500 ? -32603 : -32000, 50 | message: statusCode === 500 ? 'Internal server error' : 'Bad Request', 51 | }, 52 | id: null, 53 | }); 54 | } 55 | } 56 | 57 | app.get(Routes.ROOT, async (req: Request, res: Response) => { 58 | if (req.headers && req.get(HEADER_READINESS_PROBE) !== undefined) { 59 | log.debug('Received readiness probe'); 60 | res.status(200).json({ message: 'Server is ready' }).end(); 61 | return; 62 | } 63 | try { 64 | log.info(`Received GET message at: ${Routes.ROOT}`); 65 | // TODO: I think we should remove this logic, root should return only help message 66 | const tools = await processParamsGetTools(req.url, process.env.APIFY_TOKEN as string); 67 | if (tools) { 68 | mcpServer.upsertTools(tools); 69 | } 70 | res.setHeader('Content-Type', 'text/event-stream'); 71 | res.setHeader('Cache-Control', 'no-cache'); 72 | res.setHeader('Connection', 'keep-alive'); 73 | res.status(200).json({ message: `Actor is using Model Context Protocol. ${getHelpMessage(host)}`, data: getActorRunData() }).end(); 74 | } catch (error) { 75 | respondWithError(res, error, `Error in GET ${Routes.ROOT}`); 76 | } 77 | }); 78 | 79 | app.head(Routes.ROOT, (_req: Request, res: Response) => { 80 | res.status(200).end(); 81 | }); 82 | 83 | app.get(Routes.SSE, async (req: Request, res: Response) => { 84 | try { 85 | log.info(`Received GET message at: ${Routes.SSE}`); 86 | await loadToolsAndActors(mcpServer, req.url, process.env.APIFY_TOKEN as string); 87 | transportSSE = new SSEServerTransport(Routes.MESSAGE, res); 88 | await mcpServer.connect(transportSSE); 89 | } catch (error) { 90 | respondWithError(res, error, `Error in GET ${Routes.SSE}`); 91 | } 92 | }); 93 | 94 | app.post(Routes.MESSAGE, async (req: Request, res: Response) => { 95 | try { 96 | log.info(`Received POST message at: ${Routes.MESSAGE}`); 97 | if (transportSSE) { 98 | await transportSSE.handlePostMessage(req, res); 99 | } else { 100 | log.error('Server is not connected to the client.'); 101 | res.status(400).json({ 102 | jsonrpc: '2.0', 103 | error: { 104 | code: -32000, 105 | message: 'Bad Request: Server is not connected to the client. ' 106 | + 'Connect to the server with GET request to /sse endpoint', 107 | }, 108 | id: null, 109 | }); 110 | } 111 | } catch (error) { 112 | respondWithError(res, error, `Error in POST ${Routes.MESSAGE}`); 113 | } 114 | }); 115 | 116 | // express.json() middleware to parse JSON bodies. 117 | // It must be used before the POST /mcp route but after the GET /sse route :shrug: 118 | app.use(express.json()); 119 | app.post(Routes.MCP, async (req: Request, res: Response) => { 120 | log.info('Received MCP request:', req.body); 121 | try { 122 | // Check for existing session ID 123 | const sessionId = req.headers['mcp-session-id'] as string | undefined; 124 | let transport: StreamableHTTPServerTransport; 125 | 126 | if (sessionId && transports[sessionId]) { 127 | // Reuse existing transport 128 | transport = transports[sessionId]; 129 | } else if (!sessionId && isInitializeRequest(req.body)) { 130 | // New initialization request - use JSON response mode 131 | transport = new StreamableHTTPServerTransport({ 132 | sessionIdGenerator: () => randomUUID(), 133 | enableJsonResponse: true, // Enable JSON response mode 134 | }); 135 | // Load MCP server tools 136 | await loadToolsAndActors(mcpServer, req.url, process.env.APIFY_TOKEN as string); 137 | // Connect the transport to the MCP server BEFORE handling the request 138 | await mcpServer.connect(transport); 139 | 140 | // After handling the request, if we get a session ID back, store the transport 141 | await transport.handleRequest(req, res, req.body); 142 | 143 | // Store the transport by session ID for future requests 144 | if (transport.sessionId) { 145 | transports[transport.sessionId] = transport; 146 | } 147 | return; // Already handled 148 | } else { 149 | // Invalid request - no session ID or not initialization request 150 | res.status(400).json({ 151 | jsonrpc: '2.0', 152 | error: { 153 | code: -32000, 154 | message: 'Bad Request: No valid session ID provided or not initialization request', 155 | }, 156 | id: null, 157 | }); 158 | return; 159 | } 160 | 161 | // Handle the request with existing transport - no need to reconnect 162 | await transport.handleRequest(req, res, req.body); 163 | } catch (error) { 164 | respondWithError(res, error, 'Error handling MCP request'); 165 | } 166 | }); 167 | 168 | // Handle GET requests for SSE streams according to spec 169 | app.get(Routes.MCP, async (_req: Request, res: Response) => { 170 | // We don't support GET requests for this server 171 | // The spec requires returning 405 Method Not Allowed in this case 172 | res.status(405).set('Allow', 'POST').send('Method Not Allowed'); 173 | }); 174 | 175 | // Catch-all for undefined routes 176 | app.use((req: Request, res: Response) => { 177 | res.status(404).json({ message: `There is nothing at route ${req.method} ${req.originalUrl}. ${getHelpMessage(host)}` }).end(); 178 | }); 179 | 180 | return app; 181 | } 182 | 183 | // Helper function to detect initialize requests 184 | function isInitializeRequest(body: unknown): boolean { 185 | if (Array.isArray(body)) { 186 | return body.some((msg) => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize'); 187 | } 188 | return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize'; 189 | } 190 | -------------------------------------------------------------------------------- /src/actor/types.ts: -------------------------------------------------------------------------------- 1 | export interface ActorRunData { 2 | id?: string; 3 | actId?: string; 4 | userId?: string; 5 | startedAt?: string; 6 | finishedAt: null; 7 | status: 'RUNNING'; 8 | meta: { 9 | origin?: string; 10 | }; 11 | options: { 12 | build?: string; 13 | memoryMbytes?: string; 14 | }; 15 | buildId?: string; 16 | defaultKeyValueStoreId?: string; 17 | defaultDatasetId?: string; 18 | defaultRequestQueueId?: string; 19 | buildNumber?: string; 20 | containerUrl?: string; 21 | standbyUrl?: string; 22 | } 23 | -------------------------------------------------------------------------------- /src/actor/utils.ts: -------------------------------------------------------------------------------- 1 | import { Actor } from 'apify'; 2 | 3 | import type { ActorRunData } from './types.js'; 4 | 5 | export function getActorRunData(): ActorRunData | null { 6 | return Actor.isAtHome() ? { 7 | id: process.env.ACTOR_RUN_ID, 8 | actId: process.env.ACTOR_ID, 9 | userId: process.env.APIFY_USER_ID, 10 | startedAt: process.env.ACTOR_STARTED_AT, 11 | finishedAt: null, 12 | status: 'RUNNING', 13 | meta: { 14 | origin: process.env.APIFY_META_ORIGIN, 15 | }, 16 | options: { 17 | build: process.env.ACTOR_BUILD_NUMBER, 18 | memoryMbytes: process.env.ACTOR_MEMORY_MBYTES, 19 | }, 20 | buildId: process.env.ACTOR_BUILD_ID, 21 | defaultKeyValueStoreId: process.env.ACTOR_DEFAULT_KEY_VALUE_STORE_ID, 22 | defaultDatasetId: process.env.ACTOR_DEFAULT_DATASET_ID, 23 | defaultRequestQueueId: process.env.ACTOR_DEFAULT_REQUEST_QUEUE_ID, 24 | buildNumber: process.env.ACTOR_BUILD_NUMBER, 25 | containerUrl: process.env.ACTOR_WEB_SERVER_URL, 26 | standbyUrl: process.env.ACTOR_STANDBY_URL, 27 | } : null; 28 | } 29 | -------------------------------------------------------------------------------- /src/apify-client.ts: -------------------------------------------------------------------------------- 1 | import type { ApifyClientOptions } from 'apify'; 2 | import { ApifyClient as _ApifyClient } from 'apify-client'; 3 | import type { AxiosRequestConfig } from 'axios'; 4 | 5 | import { USER_AGENT_ORIGIN } from './const.js'; 6 | 7 | /** 8 | * Adds a User-Agent header to the request config. 9 | * @param config 10 | * @private 11 | */ 12 | function addUserAgent(config: AxiosRequestConfig): AxiosRequestConfig { 13 | const updatedConfig = { ...config }; 14 | updatedConfig.headers = updatedConfig.headers ?? {}; 15 | updatedConfig.headers['User-Agent'] = `${updatedConfig.headers['User-Agent'] ?? ''}; ${USER_AGENT_ORIGIN}`; 16 | return updatedConfig; 17 | } 18 | 19 | export function getApifyAPIBaseUrl(): string { 20 | // Workaround for Actor server where the platform APIFY_API_BASE_URL did not work with getActorDefinition from actors.ts 21 | if (process.env.APIFY_IS_AT_HOME) return 'https://api.apify.com'; 22 | return process.env.APIFY_API_BASE_URL || 'https://api.apify.com'; 23 | } 24 | 25 | export class ApifyClient extends _ApifyClient { 26 | constructor(options: ApifyClientOptions) { 27 | super({ 28 | ...options, 29 | baseUrl: getApifyAPIBaseUrl(), 30 | requestInterceptors: [addUserAgent], 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | // Actor input const 2 | export const ACTOR_README_MAX_LENGTH = 5_000; 3 | export const ACTOR_ENUM_MAX_LENGTH = 200; 4 | export const ACTOR_MAX_DESCRIPTION_LENGTH = 500; 5 | 6 | export const ACTOR_RUN_DATASET_OUTPUT_MAX_ITEMS = 5; 7 | 8 | // Actor run const 9 | export const ACTOR_MAX_MEMORY_MBYTES = 4_096; // If the Actor requires 8GB of memory, free users can't run actors-mcp-server and requested Actor 10 | 11 | // MCP Server 12 | export const SERVER_NAME = 'apify-mcp-server'; 13 | export const SERVER_VERSION = '1.0.0'; 14 | 15 | // User agent headers 16 | export const USER_AGENT_ORIGIN = 'Origin/mcp-server'; 17 | 18 | export enum HelperTools { 19 | ACTOR_ADD = 'add-actor', 20 | ACTOR_GET = 'get-actor', 21 | ACTOR_GET_DETAILS = 'get-actor-details', 22 | ACTOR_REMOVE = 'remove-actor', 23 | ACTOR_RUNS_ABORT = 'abort-actor-run', 24 | ACTOR_RUNS_GET = 'get-actor-run', 25 | ACTOR_RUNS_LOG = 'get-actor-log', 26 | ACTOR_RUN_LIST_GET = 'get-actor-run-list', 27 | DATASET_GET = 'get-dataset', 28 | DATASET_LIST_GET = 'get-dataset-list', 29 | DATASET_GET_ITEMS = 'get-dataset-items', 30 | KEY_VALUE_STORE_LIST_GET = 'get-key-value-store-list', 31 | KEY_VALUE_STORE_GET = 'get-key-value-store', 32 | KEY_VALUE_STORE_KEYS_GET = 'get-key-value-store-keys', 33 | KEY_VALUE_STORE_RECORD_GET = 'get-key-value-store-record', 34 | APIFY_MCP_HELP_TOOL = 'apify-actor-help-tool', 35 | STORE_SEARCH = 'search-actors', 36 | } 37 | 38 | export const defaults = { 39 | actors: [ 40 | 'apify/rag-web-browser', 41 | ], 42 | }; 43 | 44 | // Actor output const 45 | export const ACTOR_OUTPUT_MAX_CHARS_PER_ITEM = 5_000; 46 | export const ACTOR_OUTPUT_TRUNCATED_MESSAGE = `Output was truncated because it will not fit into context.` 47 | + `There is no reason to call this tool again! You can use ${HelperTools.DATASET_GET_ITEMS} tool to get more items from the dataset.`; 48 | 49 | export const ACTOR_ADDITIONAL_INSTRUCTIONS = `Never call/execute tool/Actor unless confirmed by the user. 50 | Workflow: When an Actor runs, it processes data and stores results in Apify storage, 51 | Datasets (for structured/tabular data) and Key-Value Store (for various data types like JSON, images, HTML). 52 | Each Actor run produces a dataset ID and key-value store ID for accessing the results. 53 | By default, the number of items returned from an Actor run is limited to ${ACTOR_RUN_DATASET_OUTPUT_MAX_ITEMS}. 54 | You can always use ${HelperTools.DATASET_GET_ITEMS} tool to get more items from the dataset. 55 | Actor run input is always stored in the key-value store, recordKey: INPUT.`; 56 | 57 | export const TOOL_CACHE_MAX_SIZE = 500; 58 | export const TOOL_CACHE_TTL_SECS = 30 * 60; 59 | -------------------------------------------------------------------------------- /src/examples/clientSse.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | /** 3 | * Connect to the MCP server using SSE transport and call a tool. 4 | * The Actors MCP Server will load default Actors. 5 | * 6 | * It requires the `APIFY_TOKEN` in the `.env` file. 7 | */ 8 | 9 | import path from 'node:path'; 10 | import { fileURLToPath } from 'node:url'; 11 | 12 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 13 | import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; 14 | import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; 15 | import dotenv from 'dotenv'; // eslint-disable-line import/no-extraneous-dependencies 16 | import type { EventSourceInit } from 'eventsource'; 17 | import { EventSource } from 'eventsource'; // eslint-disable-line import/no-extraneous-dependencies 18 | 19 | import { actorNameToToolName } from '../tools/utils.js'; 20 | 21 | const REQUEST_TIMEOUT = 120_000; // 2 minutes 22 | const filename = fileURLToPath(import.meta.url); 23 | const dirname = path.dirname(filename); 24 | 25 | dotenv.config({ path: path.resolve(dirname, '../../.env') }); 26 | 27 | const SERVER_URL = process.env.MCP_SERVER_URL_BASE || 'https://actors-mcp-server.apify.actor/sse'; 28 | // We need to change forward slash / to underscore -- in the tool name as Anthropic does not allow forward slashes in the tool name 29 | const SELECTED_TOOL = actorNameToToolName('apify/rag-web-browser'); 30 | // const QUERY = 'web browser for Anthropic'; 31 | const QUERY = 'apify'; 32 | 33 | if (!process.env.APIFY_TOKEN) { 34 | console.error('APIFY_TOKEN is required but not set in the environment variables.'); 35 | process.exit(1); 36 | } 37 | 38 | // Declare EventSource on globalThis if not available (needed for Node.js environment) 39 | declare global { 40 | 41 | // eslint-disable-next-line no-var, vars-on-top 42 | var EventSource: { 43 | new(url: string, eventSourceInitDict?: EventSourceInit): EventSource; 44 | prototype: EventSource; 45 | CONNECTING: 0; 46 | OPEN: 1; 47 | CLOSED: 2; 48 | }; 49 | } 50 | 51 | if (typeof globalThis.EventSource === 'undefined') { 52 | globalThis.EventSource = EventSource; 53 | } 54 | 55 | async function main(): Promise { 56 | const transport = new SSEClientTransport( 57 | new URL(SERVER_URL), 58 | { 59 | requestInit: { 60 | headers: { 61 | authorization: `Bearer ${process.env.APIFY_TOKEN}`, 62 | }, 63 | }, 64 | eventSourceInit: { 65 | // The EventSource package augments EventSourceInit with a "fetch" parameter. 66 | // You can use this to set additional headers on the outgoing request. 67 | // Based on this example: https://github.com/modelcontextprotocol/typescript-sdk/issues/118 68 | async fetch(input: Request | URL | string, init?: RequestInit) { 69 | const headers = new Headers(init?.headers || {}); 70 | headers.set('authorization', `Bearer ${process.env.APIFY_TOKEN}`); 71 | return fetch(input, { ...init, headers }); 72 | }, 73 | // We have to cast to "any" to use it, since it's non-standard 74 | } as any, // eslint-disable-line @typescript-eslint/no-explicit-any 75 | }, 76 | ); 77 | const client = new Client( 78 | { name: 'example-client', version: '1.0.0' }, 79 | { capabilities: {} }, 80 | ); 81 | 82 | try { 83 | // Connect to the MCP server 84 | await client.connect(transport); 85 | 86 | // List available tools 87 | const tools = await client.listTools(); 88 | console.log('Available tools:', tools); 89 | 90 | if (tools.tools.length === 0) { 91 | console.log('No tools available'); 92 | return; 93 | } 94 | 95 | const selectedTool = tools.tools.find((tool) => tool.name === SELECTED_TOOL); 96 | if (!selectedTool) { 97 | console.error(`The specified tool: ${selectedTool} is not available. Exiting.`); 98 | return; 99 | } 100 | 101 | // Call a tool 102 | console.log(`Calling actor ... ${SELECTED_TOOL}`); 103 | const result = await client.callTool( 104 | { name: SELECTED_TOOL, arguments: { query: QUERY } }, 105 | CallToolResultSchema, 106 | { timeout: REQUEST_TIMEOUT }, 107 | ); 108 | console.log('Tool result:', JSON.stringify(result, null, 2)); 109 | } catch (error: unknown) { 110 | if (error instanceof Error) { 111 | console.error('Error:', error.message); 112 | } else { 113 | console.error('An unknown error occurred:', error); 114 | } 115 | } finally { 116 | await client.close(); 117 | } 118 | } 119 | 120 | await main(); 121 | -------------------------------------------------------------------------------- /src/examples/clientStdio.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | /** 3 | * Connect to the MCP server using stdio transport and call a tool. 4 | * This script uses a selected tool without LLM involvement. 5 | * You need to provide the path to the MCP server and `APIFY_TOKEN` in the `.env` file. 6 | * You can choose actors to run in the server, for example: `apify/rag-web-browser`. 7 | */ 8 | 9 | import { execSync } from 'node:child_process'; 10 | import path from 'node:path'; 11 | import { fileURLToPath } from 'node:url'; 12 | 13 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 14 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 15 | import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; 16 | import dotenv from 'dotenv'; // eslint-disable-line import/no-extraneous-dependencies 17 | 18 | import { actorNameToToolName } from '../tools/utils.js'; 19 | 20 | // Resolve dirname equivalent in ES module 21 | const filename = fileURLToPath(import.meta.url); 22 | const dirname = path.dirname(filename); 23 | 24 | dotenv.config({ path: path.resolve(dirname, '../../.env') }); 25 | const SERVER_PATH = path.resolve(dirname, '../../dist/stdio.js'); 26 | const NODE_PATH = execSync(process.platform === 'win32' ? 'where node' : 'which node').toString().trim(); 27 | 28 | const TOOLS = 'apify/rag-web-browser,lukaskrivka/google-maps-with-contact-details'; 29 | const SELECTED_TOOL = actorNameToToolName('apify/rag-web-browser'); 30 | 31 | if (!process.env.APIFY_TOKEN) { 32 | console.error('APIFY_TOKEN is required but not set in the environment variables.'); 33 | process.exit(1); 34 | } 35 | 36 | // Create server parameters for stdio connection 37 | const transport = new StdioClientTransport({ 38 | command: NODE_PATH, 39 | args: [SERVER_PATH, '--actors', TOOLS], 40 | env: { APIFY_TOKEN: process.env.APIFY_TOKEN || '' }, 41 | }); 42 | 43 | // Create a new client instance 44 | const client = new Client( 45 | { name: 'example-client', version: '0.1.0' }, 46 | { capabilities: {} }, 47 | ); 48 | 49 | // Main function to run the example client 50 | async function run() { 51 | try { 52 | // Connect to the MCP server 53 | await client.connect(transport); 54 | 55 | // List available tools 56 | const tools = await client.listTools(); 57 | console.log('Available tools:', tools); 58 | 59 | if (tools.tools.length === 0) { 60 | console.log('No tools available'); 61 | return; 62 | } 63 | 64 | // Example: Call the first available tool 65 | const selectedTool = tools.tools.find((tool) => tool.name === SELECTED_TOOL); 66 | 67 | if (!selectedTool) { 68 | console.error(`The specified tool: ${selectedTool} is not available. Exiting.`); 69 | return; 70 | } 71 | 72 | // Call a tool 73 | console.log('Calling actor ...'); 74 | const result = await client.callTool( 75 | { name: SELECTED_TOOL, arguments: { query: 'web browser for Anthropic' } }, 76 | CallToolResultSchema, 77 | ); 78 | console.log('Tool result:', JSON.stringify(result)); 79 | 80 | await client.close(); 81 | } catch (error) { 82 | console.error('Error:', error); 83 | } 84 | } 85 | 86 | run().catch((error) => { 87 | console.error(`Error running MCP client: ${error as Error}`); 88 | process.exit(1); 89 | }); 90 | -------------------------------------------------------------------------------- /src/examples/clientStdioChat.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | /** 3 | * Create a simple chat client that connects to the Model Context Protocol server using the stdio transport. 4 | * Based on the user input, the client sends a query to the MCP server, retrieves results and processes them. 5 | * 6 | * You can expect the following output: 7 | * 8 | * MCP Client Started! 9 | * Type your queries or 'quit|q|exit' to exit. 10 | * You: Find to articles about AI agent and return URLs 11 | * [internal] Received response from Claude: [{"type":"text","text":"I'll search for information about AI agents 12 | * and provide you with a summary."},{"type":"tool_use","id":"tool_01He9TkzQfh2979bbeuxWVqM","name":"search", 13 | * "input":{"query":"what are AI agents definition capabilities applications","maxResults":2}}] 14 | * [internal] Calling tool: {"name":"search","arguments":{"query":"what are AI agents definition ... 15 | * I can help analyze the provided content about AI agents. 16 | * This appears to be crawled content from AWS and IBM websites explaining what AI agents are. 17 | * Let me summarize the key points: 18 | */ 19 | 20 | import { execSync } from 'node:child_process'; 21 | import path from 'node:path'; 22 | import * as readline from 'node:readline'; 23 | import { fileURLToPath } from 'node:url'; 24 | 25 | import { Anthropic } from '@anthropic-ai/sdk'; // eslint-disable-line import/no-extraneous-dependencies 26 | import type { Message, MessageParam, ToolUseBlock } from '@anthropic-ai/sdk/resources/messages'; 27 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 28 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 29 | import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; 30 | import dotenv from 'dotenv'; // eslint-disable-line import/no-extraneous-dependencies 31 | 32 | const filename = fileURLToPath(import.meta.url); 33 | const dirname = path.dirname(filename); 34 | 35 | dotenv.config({ path: path.resolve(dirname, '../../.env') }); 36 | 37 | const REQUEST_TIMEOUT = 120_000; // 2 minutes 38 | const MAX_TOKENS = 2048; // Maximum tokens for Claude response 39 | 40 | // const CLAUDE_MODEL = 'claude-3-5-sonnet-20241022'; // the most intelligent model 41 | // const CLAUDE_MODEL = 'claude-3-5-haiku-20241022'; // a fastest model 42 | const CLAUDE_MODEL = 'claude-3-haiku-20240307'; // a fastest and most compact model for near-instant responsiveness 43 | const DEBUG = true; 44 | const DEBUG_SERVER_PATH = path.resolve(dirname, '../../dist/stdio.js'); 45 | 46 | const NODE_PATH = execSync('which node').toString().trim(); 47 | 48 | dotenv.config(); // Load environment variables from .env 49 | 50 | export type Tool = { 51 | name: string; 52 | description: string | undefined; 53 | input_schema: unknown; 54 | } 55 | 56 | class MCPClient { 57 | private anthropic: Anthropic; 58 | private client = new Client( 59 | { 60 | name: 'example-client', 61 | version: '0.1.0', 62 | }, 63 | { 64 | capabilities: {}, // Optional capabilities 65 | }, 66 | ); 67 | 68 | private tools: Tool[] = []; 69 | 70 | constructor() { 71 | this.anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); 72 | } 73 | 74 | /** 75 | * Start the server using node and provided server script path. 76 | * Connect to the server using stdio transport and list available tools. 77 | */ 78 | async connectToServer(serverArgs: string[]) { 79 | const transport = new StdioClientTransport({ 80 | command: NODE_PATH, 81 | args: serverArgs, 82 | env: { APIFY_TOKEN: process.env.APIFY_TOKEN || '' }, 83 | }); 84 | 85 | await this.client.connect(transport); 86 | const response = await this.client.listTools(); 87 | 88 | this.tools = response.tools.map((x) => ({ 89 | name: x.name, 90 | description: x.description, 91 | input_schema: x.inputSchema, 92 | })); 93 | console.log('Connected to server with tools:', this.tools.map((x) => x.name)); 94 | } 95 | 96 | /** 97 | * Process LLM response and check whether it contains any tool calls. 98 | * If a tool call is found, call the tool and return the response and save the results to messages with type: user. 99 | * If the tools response is too large, truncate it to the limit. 100 | */ 101 | async processMsg(response: Message, messages: MessageParam[]): Promise { 102 | for (const content of response.content) { 103 | if (content.type === 'text') { 104 | messages.push({ role: 'assistant', content: content.text }); 105 | } else if (content.type === 'tool_use') { 106 | await this.handleToolCall(content, messages); 107 | } 108 | } 109 | return messages; 110 | } 111 | 112 | /** 113 | * Call the tool and return the response. 114 | */ 115 | private async handleToolCall(content: ToolUseBlock, messages: MessageParam[], toolCallCount = 0): Promise { 116 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 117 | const params = { name: content.name, arguments: content.input as any }; 118 | console.log(`[internal] Calling tool (count: ${toolCallCount}): ${JSON.stringify(params)}`); 119 | let results; 120 | try { 121 | results = await this.client.callTool(params, CallToolResultSchema, { timeout: REQUEST_TIMEOUT }); 122 | if (results.content instanceof Array && results.content.length !== 0) { 123 | const text = results.content.map((x) => x.text); 124 | messages.push({ role: 'user', content: `Tool result: ${text.join('\n\n')}` }); 125 | } else { 126 | messages.push({ role: 'user', content: `No results retrieved from ${params.name}` }); 127 | } 128 | } catch (error) { 129 | messages.push({ role: 'user', content: `Error calling tool: ${params.name}, error: ${error}` }); 130 | } 131 | // Get next response from Claude 132 | const nextResponse: Message = await this.anthropic.messages.create({ 133 | model: CLAUDE_MODEL, 134 | max_tokens: MAX_TOKENS, 135 | messages, 136 | tools: this.tools as any[], // eslint-disable-line @typescript-eslint/no-explicit-any 137 | }); 138 | 139 | for (const c of nextResponse.content) { 140 | if (c.type === 'text') { 141 | messages.push({ role: 'assistant', content: c.text }); 142 | } else if (c.type === 'tool_use' && toolCallCount < 3) { 143 | return await this.handleToolCall(c, messages, toolCallCount + 1); 144 | } 145 | } 146 | 147 | return messages; 148 | } 149 | 150 | /** 151 | * Process user query by sending it to the server and returning the response. 152 | * Also, process any tool calls. 153 | */ 154 | async processQuery(query: string, messages: MessageParam[]): Promise { 155 | messages.push({ role: 'user', content: query }); 156 | const response: Message = await this.anthropic.messages.create({ 157 | model: CLAUDE_MODEL, 158 | max_tokens: MAX_TOKENS, 159 | messages, 160 | tools: this.tools as any[], // eslint-disable-line @typescript-eslint/no-explicit-any 161 | }); 162 | console.log('[internal] Received response from Claude:', JSON.stringify(response.content)); 163 | return await this.processMsg(response, messages); 164 | } 165 | 166 | /** 167 | * Create a chat loop that reads user input from the console and sends it to the server for processing. 168 | */ 169 | async chatLoop() { 170 | const rl = readline.createInterface({ 171 | input: process.stdin, 172 | output: process.stdout, 173 | prompt: 'You: ', 174 | }); 175 | 176 | console.log("MCP Client Started!\nType your queries or 'quit|q|exit' to exit."); 177 | rl.prompt(); 178 | 179 | let lastPrintMessage = 0; 180 | const messages: MessageParam[] = []; 181 | rl.on('line', async (input) => { 182 | const v = input.trim().toLowerCase(); 183 | if (v === 'quit' || v === 'q' || v === 'exit') { 184 | rl.close(); 185 | return; 186 | } 187 | try { 188 | await this.processQuery(input, messages); 189 | for (let i = lastPrintMessage + 1; i < messages.length; i++) { 190 | if (messages[i].role === 'assistant') { 191 | console.log('CLAUDE:', messages[i].content); 192 | } else if (messages[i].role === 'user') { 193 | console.log('USER:', messages[i].content.slice(0, 500), '...'); 194 | } else { 195 | console.log('CLAUDE[thinking]:', messages[i].content); 196 | } 197 | } 198 | lastPrintMessage += messages.length; 199 | } catch (error) { 200 | console.error('Error processing query:', error); 201 | } 202 | rl.prompt(); 203 | }); 204 | } 205 | } 206 | 207 | async function main() { 208 | const client = new MCPClient(); 209 | 210 | if (process.argv.length < 3) { 211 | if (DEBUG) { 212 | process.argv.push(DEBUG_SERVER_PATH); 213 | } else { 214 | console.error('Usage: node '); 215 | process.exit(1); 216 | } 217 | } 218 | 219 | try { 220 | await client.connectToServer(process.argv.slice(2)); 221 | await client.chatLoop(); 222 | } catch (error) { 223 | console.error('Error:', error); 224 | } 225 | } 226 | 227 | main().catch(console.error); 228 | -------------------------------------------------------------------------------- /src/examples/clientStreamableHttp.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 2 | import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; 3 | import type { CallToolRequest, ListToolsRequest } from '@modelcontextprotocol/sdk/types.js'; 4 | import { 5 | CallToolResultSchema, 6 | ListToolsResultSchema, 7 | LoggingMessageNotificationSchema, 8 | } from '@modelcontextprotocol/sdk/types.js'; 9 | 10 | import log from '@apify/log'; 11 | 12 | import { HelperTools } from '../const.js'; 13 | 14 | log.setLevel(log.LEVELS.DEBUG); 15 | 16 | async function main(): Promise { 17 | // Create a new client with streamable HTTP transport 18 | const client = new Client({ 19 | name: 'example-client', 20 | version: '1.0.0', 21 | }); 22 | 23 | const transport = new StreamableHTTPClientTransport( 24 | new URL('http://localhost:3000/mcp'), 25 | ); 26 | 27 | // Connect the client using the transport and initialize the server 28 | await client.connect(transport); 29 | log.debug('Connected to MCP server'); 30 | 31 | // Set up notification handlers for server-initiated messages 32 | client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { 33 | log.debug(`Notification received: ${notification.params.level} - ${notification.params.data}`); 34 | }); 35 | 36 | // List and call tools 37 | await listTools(client); 38 | 39 | await callSearchTool(client); 40 | await callActor(client); 41 | 42 | // Keep the connection open to receive notifications 43 | log.debug('\nKeeping connection open to receive notifications. Press Ctrl+C to exit.'); 44 | } 45 | 46 | async function listTools(client: Client): Promise { 47 | try { 48 | const toolsRequest: ListToolsRequest = { 49 | method: 'tools/list', 50 | params: {}, 51 | }; 52 | const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); 53 | log.debug(`Tools available, count: ${toolsResult.tools.length}`); 54 | for (const tool of toolsResult.tools) { 55 | log.debug(`Tool: ${tool.name}, Description: ${tool.description}`); 56 | } 57 | if (toolsResult.tools.length === 0) { 58 | log.debug('No tools available from the server'); 59 | } 60 | } catch (error) { 61 | log.error(`Tools not supported by this server (${error})`); 62 | } 63 | } 64 | 65 | async function callSearchTool(client: Client): Promise { 66 | try { 67 | const searchRequest: CallToolRequest = { 68 | method: 'tools/call', 69 | params: { 70 | name: HelperTools.STORE_SEARCH, 71 | arguments: { search: 'rag web browser', limit: 1 }, 72 | }, 73 | }; 74 | const searchResult = await client.request(searchRequest, CallToolResultSchema); 75 | log.debug('Search result:'); 76 | const resultContent = searchResult.content || []; 77 | resultContent.forEach((item) => { 78 | if (item.type === 'text') { 79 | log.debug(`\t${item.text}`); 80 | } 81 | }); 82 | } catch (error) { 83 | log.error(`Error calling greet tool: ${error}`); 84 | } 85 | } 86 | 87 | async function callActor(client: Client): Promise { 88 | try { 89 | log.debug('\nCalling Actor...'); 90 | const actorRequest: CallToolRequest = { 91 | method: 'tools/call', 92 | params: { 93 | name: 'apify/rag-web-browser', 94 | arguments: { query: 'apify mcp server' }, 95 | }, 96 | }; 97 | const actorResult = await client.request(actorRequest, CallToolResultSchema); 98 | log.debug('Actor results:'); 99 | const resultContent = actorResult.content || []; 100 | resultContent.forEach((item) => { 101 | if (item.type === 'text') { 102 | log.debug(`- ${item.text}`); 103 | } 104 | }); 105 | } catch (error) { 106 | log.error(`Error calling Actor: ${error}`); 107 | } 108 | } 109 | 110 | main().catch((error: unknown) => { 111 | log.error(`Error running MCP client: ${error as Error}`); 112 | process.exit(1); 113 | }); 114 | -------------------------------------------------------------------------------- /src/examples/client_sse.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test Apify MCP Server using SSE client 3 | 4 | It is using python client as the typescript one does not support custom headers when connecting to the SSE server. 5 | 6 | Install python dependencies (assumes you have python installed): 7 | > pip install requests python-dotenv mcp 8 | """ 9 | 10 | import asyncio 11 | import os 12 | from pathlib import Path 13 | 14 | import requests 15 | from dotenv import load_dotenv 16 | from mcp.client.session import ClientSession 17 | from mcp.client.sse import sse_client 18 | 19 | load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env") 20 | 21 | MCP_SERVER_URL = "https://actors-mcp-server.apify.actor" 22 | 23 | HEADERS = {"Authorization": f"Bearer {os.getenv('APIFY_TOKEN')}"} 24 | 25 | async def run() -> None: 26 | 27 | print("Start MCP Server with Actors") 28 | r = requests.get(MCP_SERVER_URL, headers=HEADERS) 29 | print("MCP Server Response:", r.json(), end="\n\n") 30 | 31 | async with sse_client(url=f"{MCP_SERVER_URL}/sse", timeout=60, headers=HEADERS) as (read, write): 32 | async with ClientSession(read, write) as session: 33 | await session.initialize() 34 | 35 | tools = await session.list_tools() 36 | print("Available Tools:", tools, end="\n\n") 37 | for tool in tools.tools: 38 | print(f"\n### Tool name ###: {tool.name}") 39 | print(f"\tdescription: {tool.description}") 40 | print(f"\tinputSchema: {tool.inputSchema}") 41 | 42 | if hasattr(tools, "tools") and not tools.tools: 43 | print("No tools available!") 44 | return 45 | 46 | print("\n\nCall tool") 47 | result = await session.call_tool("apify/rag-web-browser", { "query": "example.com", "maxResults": 3 }) 48 | print("Tools call result:") 49 | 50 | for content in result.content: 51 | print(content) 52 | 53 | asyncio.run(run()) 54 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | This file provides essential functions and tools for MCP servers, serving as a library. 3 | The ActorsMcpServer should be the only class exported from the package 4 | */ 5 | 6 | import { ActorsMcpServer } from './mcp/server.js'; 7 | 8 | export { ActorsMcpServer }; 9 | -------------------------------------------------------------------------------- /src/input.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Actor input processing. 3 | */ 4 | import log from '@apify/log'; 5 | 6 | import type { Input } from './types.js'; 7 | 8 | /** 9 | * Process input parameters, split Actors string into an array 10 | * @param originalInput 11 | * @returns input 12 | */ 13 | export function processInput(originalInput: Partial): Input { 14 | const input = originalInput as Input; 15 | 16 | // actors can be a string or an array of strings 17 | if (input.actors && typeof input.actors === 'string') { 18 | input.actors = input.actors.split(',').map((format: string) => format.trim()) as string[]; 19 | } 20 | 21 | // enableAddingActors is deprecated, use enableActorAutoLoading instead 22 | if (input.enableAddingActors === undefined) { 23 | if (input.enableActorAutoLoading !== undefined) { 24 | log.warning('enableActorAutoLoading is deprecated, use enableAddingActors instead'); 25 | input.enableAddingActors = input.enableActorAutoLoading === true || input.enableActorAutoLoading === 'true'; 26 | } else { 27 | input.enableAddingActors = false; 28 | } 29 | } else { 30 | input.enableAddingActors = input.enableAddingActors === true || input.enableAddingActors === 'true'; 31 | } 32 | return input; 33 | } 34 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Serves as an Actor MCP SSE server entry point. 3 | * This file needs to be named `main.ts` to be recognized by the Apify platform. 4 | */ 5 | 6 | import { Actor } from 'apify'; 7 | import type { ActorCallOptions } from 'apify-client'; 8 | 9 | import log from '@apify/log'; 10 | 11 | import { createExpressApp } from './actor/server.js'; 12 | import { processInput } from './input.js'; 13 | import { ActorsMcpServer } from './mcp/server.js'; 14 | import { callActorGetDataset, getActorsAsTools } from './tools/index.js'; 15 | import type { Input } from './types.js'; 16 | 17 | const STANDBY_MODE = Actor.getEnv().metaOrigin === 'STANDBY'; 18 | 19 | await Actor.init(); 20 | 21 | const HOST = Actor.isAtHome() ? process.env.ACTOR_STANDBY_URL as string : 'http://localhost'; 22 | const PORT = Actor.isAtHome() ? Number(process.env.ACTOR_STANDBY_PORT) : 3001; 23 | 24 | if (!process.env.APIFY_TOKEN) { 25 | log.error('APIFY_TOKEN is required but not set in the environment variables.'); 26 | process.exit(1); 27 | } 28 | 29 | const input = processInput((await Actor.getInput>()) ?? ({} as Input)); 30 | log.info(`Loaded input: ${JSON.stringify(input)} `); 31 | 32 | if (STANDBY_MODE) { 33 | const mcpServer = new ActorsMcpServer({ 34 | enableAddingActors: Boolean(input.enableAddingActors), 35 | enableDefaultActors: false, 36 | }); 37 | 38 | const app = createExpressApp(HOST, mcpServer); 39 | log.info('Actor is running in the STANDBY mode.'); 40 | 41 | // Load only Actors specified in the input 42 | // If you wish to start without any Actor, create a task and leave the input empty 43 | if (input.actors && input.actors.length > 0) { 44 | const { actors } = input; 45 | const actorsToLoad = Array.isArray(actors) ? actors : actors.split(','); 46 | const tools = await getActorsAsTools(actorsToLoad, process.env.APIFY_TOKEN as string); 47 | mcpServer.upsertTools(tools); 48 | } 49 | app.listen(PORT, () => { 50 | log.info(`The Actor web server is listening for user requests at ${HOST}`); 51 | }); 52 | } else { 53 | log.info('Actor is not designed to run in the NORMAL model (use this mode only for debugging purposes)'); 54 | 55 | if (input && !input.debugActor && !input.debugActorInput) { 56 | await Actor.fail('If you need to debug a specific Actor, please provide the debugActor and debugActorInput fields in the input'); 57 | } 58 | const options = { memory: input.maxActorMemoryBytes } as ActorCallOptions; 59 | const { datasetInfo, items } = await callActorGetDataset(input.debugActor!, input.debugActorInput!, process.env.APIFY_TOKEN, options); 60 | 61 | await Actor.pushData(items); 62 | log.info(`Pushed ${datasetInfo?.itemCount} items to the dataset`); 63 | await Actor.exit(); 64 | } 65 | -------------------------------------------------------------------------------- /src/mcp/actors.ts: -------------------------------------------------------------------------------- 1 | import type { ActorDefinition } from 'apify-client'; 2 | 3 | import { ApifyClient } from '../apify-client.js'; 4 | 5 | export async function isActorMCPServer(actorID: string, apifyToken: string): Promise { 6 | const mcpPath = await getActorsMCPServerPath(actorID, apifyToken); 7 | return (mcpPath?.length || 0) > 0; 8 | } 9 | 10 | export async function getActorsMCPServerPath(actorID: string, apifyToken: string): Promise { 11 | const actorDefinition = await getActorDefinition(actorID, apifyToken); 12 | 13 | if ('webServerMcpPath' in actorDefinition && typeof actorDefinition.webServerMcpPath === 'string') { 14 | return actorDefinition.webServerMcpPath; 15 | } 16 | 17 | return undefined; 18 | } 19 | 20 | export async function getActorsMCPServerURL(actorID: string, apifyToken: string): Promise { 21 | // TODO: get from API instead 22 | const standbyBaseUrl = process.env.HOSTNAME === 'mcp-securitybyobscurity.apify.com' 23 | ? 'securitybyobscurity.apify.actor' : 'apify.actor'; 24 | const standbyUrl = await getActorStandbyURL(actorID, apifyToken, standbyBaseUrl); 25 | const mcpPath = await getActorsMCPServerPath(actorID, apifyToken); 26 | return `${standbyUrl}${mcpPath}`; 27 | } 28 | 29 | /** 30 | * Gets Actor ID from the Actor object. 31 | * 32 | * @param actorID 33 | * @param apifyToken 34 | */ 35 | export async function getRealActorID(actorID: string, apifyToken: string): Promise { 36 | const apifyClient = new ApifyClient({ token: apifyToken }); 37 | 38 | const actor = apifyClient.actor(actorID); 39 | const info = await actor.get(); 40 | if (!info) { 41 | throw new Error(`Actor ${actorID} not found`); 42 | } 43 | return info.id; 44 | } 45 | 46 | /** 47 | * Returns standby URL for given Actor ID. 48 | * 49 | * @param actorID 50 | * @param standbyBaseUrl 51 | * @param apifyToken 52 | * @returns 53 | */ 54 | export async function getActorStandbyURL(actorID: string, apifyToken: string, standbyBaseUrl = 'apify.actor'): Promise { 55 | const actorRealID = await getRealActorID(actorID, apifyToken); 56 | return `https://${actorRealID}.${standbyBaseUrl}`; 57 | } 58 | 59 | export async function getActorDefinition(actorID: string, apifyToken: string): Promise { 60 | const apifyClient = new ApifyClient({ token: apifyToken }); 61 | const actor = apifyClient.actor(actorID); 62 | const defaultBuildClient = await actor.defaultBuild(); 63 | const buildInfo = await defaultBuildClient.get(); 64 | if (!buildInfo) { 65 | throw new Error(`Default build for Actor ${actorID} not found`); 66 | } 67 | const { actorDefinition } = buildInfo; 68 | if (!actorDefinition) { 69 | throw new Error(`Actor default build ${actorID} does not have Actor definition`); 70 | } 71 | 72 | return actorDefinition; 73 | } 74 | -------------------------------------------------------------------------------- /src/mcp/client.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 2 | import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; 3 | 4 | import { getMCPServerID } from './utils.js'; 5 | 6 | /** 7 | * Creates and connects a ModelContextProtocol client. 8 | */ 9 | export async function createMCPClient( 10 | url: string, token: string, 11 | ): Promise { 12 | const transport = new SSEClientTransport( 13 | new URL(url), 14 | { 15 | requestInit: { 16 | headers: { 17 | authorization: `Bearer ${token}`, 18 | }, 19 | }, 20 | eventSourceInit: { 21 | // The EventSource package augments EventSourceInit with a "fetch" parameter. 22 | // You can use this to set additional headers on the outgoing request. 23 | // Based on this example: https://github.com/modelcontextprotocol/typescript-sdk/issues/118 24 | async fetch(input: Request | URL | string, init?: RequestInit) { 25 | const headers = new Headers(init?.headers || {}); 26 | headers.set('authorization', `Bearer ${token}`); 27 | return fetch(input, { ...init, headers }); 28 | }, 29 | // We have to cast to "any" to use it, since it's non-standard 30 | } as any, // eslint-disable-line @typescript-eslint/no-explicit-any 31 | }); 32 | 33 | const client = new Client({ 34 | name: getMCPServerID(url), 35 | version: '1.0.0', 36 | }); 37 | 38 | await client.connect(transport); 39 | 40 | return client; 41 | } 42 | -------------------------------------------------------------------------------- /src/mcp/const.ts: -------------------------------------------------------------------------------- 1 | export const MAX_TOOL_NAME_LENGTH = 64; 2 | export const SERVER_ID_LENGTH = 8; 3 | export const EXTERNAL_TOOL_CALL_TIMEOUT_MSEC = 120_000; // 2 minutes 4 | -------------------------------------------------------------------------------- /src/mcp/proxy.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; 2 | import Ajv from 'ajv'; 3 | 4 | import type { ActorMcpTool, ToolEntry } from '../types.js'; 5 | import { getMCPServerID, getProxyMCPServerToolName } from './utils.js'; 6 | 7 | export async function getMCPServerTools( 8 | actorID: string, 9 | client: Client, 10 | // Name of the MCP server 11 | serverUrl: string, 12 | ): Promise { 13 | const res = await client.listTools(); 14 | const { tools } = res; 15 | 16 | const ajv = new Ajv({ coerceTypes: 'array', strict: false }); 17 | 18 | const compiledTools: ToolEntry[] = []; 19 | for (const tool of tools) { 20 | const mcpTool: ActorMcpTool = { 21 | actorId: actorID, 22 | serverId: getMCPServerID(serverUrl), 23 | serverUrl, 24 | originToolName: tool.name, 25 | 26 | name: getProxyMCPServerToolName(serverUrl, tool.name), 27 | description: tool.description || '', 28 | inputSchema: tool.inputSchema, 29 | ajvValidate: ajv.compile(tool.inputSchema), 30 | }; 31 | 32 | const wrap: ToolEntry = { 33 | type: 'actor-mcp', 34 | tool: mcpTool, 35 | }; 36 | 37 | compiledTools.push(wrap); 38 | } 39 | 40 | return compiledTools; 41 | } 42 | -------------------------------------------------------------------------------- /src/mcp/utils.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'node:crypto'; 2 | import { parse } from 'node:querystring'; 3 | 4 | import { processInput } from '../input.js'; 5 | import { addRemoveTools, getActorsAsTools } from '../tools/index.js'; 6 | import type { Input, ToolEntry } from '../types.js'; 7 | import { MAX_TOOL_NAME_LENGTH, SERVER_ID_LENGTH } from './const.js'; 8 | 9 | /** 10 | * Generates a unique server ID based on the provided URL. 11 | * 12 | * URL is used instead of Actor ID because one Actor may expose multiple servers - legacy SSE / streamable HTTP. 13 | * 14 | * @param url The URL to generate the server ID from. 15 | * @returns A unique server ID. 16 | */ 17 | export function getMCPServerID(url: string): string { 18 | const serverHashDigest = createHash('sha256').update(url).digest('hex'); 19 | 20 | return serverHashDigest.slice(0, SERVER_ID_LENGTH); 21 | } 22 | 23 | /** 24 | * Generates a unique tool name based on the provided URL and tool name. 25 | * @param url The URL to generate the tool name from. 26 | * @param toolName The tool name to generate the tool name from. 27 | * @returns A unique tool name. 28 | */ 29 | export function getProxyMCPServerToolName(url: string, toolName: string): string { 30 | const prefix = getMCPServerID(url); 31 | 32 | const fullName = `${prefix}-${toolName}`; 33 | return fullName.slice(0, MAX_TOOL_NAME_LENGTH); 34 | } 35 | 36 | /** 37 | * Process input parameters and get tools 38 | * If URL contains query parameter `actors`, return tools from Actors otherwise return null. 39 | * @param url 40 | * @param apifyToken 41 | */ 42 | export async function processParamsGetTools(url: string, apifyToken: string) { 43 | const input = parseInputParamsFromUrl(url); 44 | let tools: ToolEntry[] = []; 45 | if (input.actors) { 46 | const actors = input.actors as string[]; 47 | // Normal Actors as a tool 48 | tools = await getActorsAsTools(actors, apifyToken); 49 | } 50 | if (input.enableAddingActors) { 51 | tools.push(...addRemoveTools); 52 | } 53 | return tools; 54 | } 55 | 56 | export function parseInputParamsFromUrl(url: string): Input { 57 | const query = url.split('?')[1] || ''; 58 | const params = parse(query) as unknown as Input; 59 | return processInput(params); 60 | } 61 | -------------------------------------------------------------------------------- /src/stdio.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * This script initializes and starts the Apify MCP server using the Stdio transport. 4 | * 5 | * Usage: 6 | * node --actors= 7 | * 8 | * Command-line arguments: 9 | * --actors - A comma-separated list of Actor full names to add to the server. 10 | * --help - Display help information 11 | * 12 | * Example: 13 | * node stdio.js --actors=apify/google-search-scraper,apify/instagram-scraper 14 | */ 15 | 16 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 17 | import yargs from 'yargs'; 18 | // Had to ignore the eslint import extension error for the yargs package. 19 | // Using .js or /index.js didn't resolve it due to the @types package issues. 20 | // eslint-disable-next-line import/extensions 21 | import { hideBin } from 'yargs/helpers'; 22 | 23 | import log from '@apify/log'; 24 | 25 | import { defaults } from './const.js'; 26 | import { ActorsMcpServer } from './mcp/server.js'; 27 | import { getActorsAsTools } from './tools/index.js'; 28 | 29 | // Keeping this interface here and not types.ts since 30 | // it is only relevant to the CLI/STDIO transport in this file 31 | /** 32 | * Interface for command line arguments 33 | */ 34 | interface CliArgs { 35 | actors?: string; 36 | 'enable-adding-actors'?: boolean; 37 | enableActorAutoLoading?: boolean; 38 | } 39 | 40 | // Configure logging, set to ERROR 41 | log.setLevel(log.LEVELS.ERROR); 42 | 43 | // Parse command line arguments using yargs 44 | const argv = yargs(hideBin(process.argv)) 45 | .usage('Usage: $0 [options]') 46 | .option('actors', { 47 | type: 'string', 48 | describe: 'Comma-separated list of Actor full names to add to the server', 49 | example: 'apify/google-search-scraper,apify/instagram-scraper', 50 | }) 51 | .option('enable-adding-actors', { 52 | type: 'boolean', 53 | default: false, 54 | describe: 'Enable dynamically adding Actors as tools based on user requests', 55 | }) 56 | .option('enableActorAutoLoading', { 57 | type: 'boolean', 58 | default: false, 59 | hidden: true, 60 | describe: 'Deprecated: use enable-adding-actors instead', 61 | }) 62 | .help('help') 63 | .alias('h', 'help') 64 | .version(false) 65 | .epilogue( 66 | 'To connect, set your MCP client server command to `npx @apify/actors-mcp-server`' 67 | + ' and set the environment variable `APIFY_TOKEN` to your Apify API token.\n', 68 | ) 69 | .epilogue('For more information, visit https://github.com/apify/actors-mcp-server') 70 | .parseSync() as CliArgs; 71 | 72 | const enableAddingActors = argv['enable-adding-actors'] || argv.enableActorAutoLoading || false; 73 | const actors = argv.actors as string || ''; 74 | const actorList = actors ? actors.split(',').map((a: string) => a.trim()) : []; 75 | 76 | // Validate environment 77 | if (!process.env.APIFY_TOKEN) { 78 | log.error('APIFY_TOKEN is required but not set in the environment variables.'); 79 | process.exit(1); 80 | } 81 | 82 | async function main() { 83 | const mcpServer = new ActorsMcpServer({ enableAddingActors, enableDefaultActors: false }); 84 | const tools = await getActorsAsTools(actorList.length ? actorList : defaults.actors, process.env.APIFY_TOKEN as string); 85 | mcpServer.upsertTools(tools); 86 | 87 | // Start server 88 | const transport = new StdioServerTransport(); 89 | await mcpServer.connect(transport); 90 | } 91 | 92 | main().catch((error) => { 93 | log.error(`Server error: ${error}`); 94 | process.exit(1); 95 | }); 96 | -------------------------------------------------------------------------------- /src/tools/actor.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; 2 | import { Ajv } from 'ajv'; 3 | import type { ActorCallOptions, ActorRun, Dataset, PaginatedList } from 'apify-client'; 4 | import { z } from 'zod'; 5 | import zodToJsonSchema from 'zod-to-json-schema'; 6 | 7 | import { LruCache } from '@apify/datastructures'; 8 | import log from '@apify/log'; 9 | 10 | import { ApifyClient } from '../apify-client.js'; 11 | import { 12 | ACTOR_ADDITIONAL_INSTRUCTIONS, 13 | ACTOR_MAX_MEMORY_MBYTES, 14 | ACTOR_RUN_DATASET_OUTPUT_MAX_ITEMS, 15 | HelperTools, 16 | TOOL_CACHE_MAX_SIZE, 17 | TOOL_CACHE_TTL_SECS, 18 | } from '../const.js'; 19 | import { getActorsMCPServerURL, isActorMCPServer } from '../mcp/actors.js'; 20 | import { createMCPClient } from '../mcp/client.js'; 21 | import { getMCPServerTools } from '../mcp/proxy.js'; 22 | import type { InternalTool, ToolCacheEntry, ToolEntry } from '../types.js'; 23 | import { getActorDefinition } from './build.js'; 24 | import { 25 | actorNameToToolName, 26 | addEnumsToDescriptionsWithExamples, 27 | buildNestedProperties, 28 | filterSchemaProperties, 29 | markInputPropertiesAsRequired, 30 | shortenProperties, 31 | } from './utils.js'; 32 | 33 | const ajv = new Ajv({ coerceTypes: 'array', strict: false }); 34 | 35 | // Define a named return type for callActorGetDataset 36 | export type CallActorGetDatasetResult = { 37 | actorRun: ActorRun; 38 | datasetInfo: Dataset | undefined; 39 | items: PaginatedList>; 40 | }; 41 | 42 | // Cache for normal Actor tools 43 | const normalActorToolsCache = new LruCache({ 44 | maxLength: TOOL_CACHE_MAX_SIZE, 45 | }); 46 | 47 | /** 48 | * Calls an Apify actor and retrieves the dataset items. 49 | * 50 | * 51 | * It requires the `APIFY_TOKEN` environment variable to be set. 52 | * If the `APIFY_IS_AT_HOME` the dataset items are pushed to the Apify dataset. 53 | * 54 | * @param {string} actorName - The name of the actor to call. 55 | * @param {ActorCallOptions} callOptions - The options to pass to the actor. 56 | * @param {unknown} input - The input to pass to the actor. 57 | * @param {string} apifyToken - The Apify token to use for authentication. 58 | * @param {number} limit - The maximum number of items to retrieve from the dataset. 59 | * @returns {Promise<{ actorRun: any, items: object[] }>} - A promise that resolves to an object containing the actor run and dataset items. 60 | * @throws {Error} - Throws an error if the `APIFY_TOKEN` is not set 61 | */ 62 | export async function callActorGetDataset( 63 | actorName: string, 64 | input: unknown, 65 | apifyToken: string, 66 | callOptions: ActorCallOptions | undefined = undefined, 67 | limit = ACTOR_RUN_DATASET_OUTPUT_MAX_ITEMS, 68 | ): Promise { 69 | try { 70 | log.info(`Calling Actor ${actorName} with input: ${JSON.stringify(input)}`); 71 | 72 | const client = new ApifyClient({ token: apifyToken }); 73 | const actorClient = client.actor(actorName); 74 | 75 | const actorRun: ActorRun = await actorClient.call(input, callOptions); 76 | const dataset = client.dataset(actorRun.defaultDatasetId); 77 | const [datasetInfo, items] = await Promise.all([ 78 | dataset.get(), 79 | dataset.listItems({ limit }), 80 | ]); 81 | log.info(`Actor ${actorName} finished with ${datasetInfo?.itemCount} items`); 82 | 83 | return { actorRun, datasetInfo, items }; 84 | } catch (error) { 85 | log.error(`Error calling actor: ${error}. Actor: ${actorName}, input: ${JSON.stringify(input)}`); 86 | throw new Error(`Error calling Actor: ${error}`); 87 | } 88 | } 89 | 90 | /** 91 | * This function is used to fetch normal non-MCP server Actors as a tool. 92 | * 93 | * Fetches actor input schemas by Actor IDs or Actor full names and creates MCP tools. 94 | * 95 | * This function retrieves the input schemas for the specified actors and compiles them into MCP tools. 96 | * It uses the AJV library to validate the input schemas. 97 | * 98 | * Tool name can't contain /, so it is replaced with _ 99 | * 100 | * The input schema processing workflow: 101 | * 1. Properties are marked as required using markInputPropertiesAsRequired() to add "REQUIRED" prefix to descriptions 102 | * 2. Nested properties are built by analyzing editor type (proxy, requestListSources) using buildNestedProperties() 103 | * 3. Properties are filtered using filterSchemaProperties() 104 | * 4. Properties are shortened using shortenProperties() 105 | * 5. Enums are added to descriptions with examples using addEnumsToDescriptionsWithExamples() 106 | * 107 | * @param {string[]} actors - An array of actor IDs or Actor full names. 108 | * @param {string} apifyToken - The Apify token to use for authentication. 109 | * @returns {Promise} - A promise that resolves to an array of MCP tools. 110 | */ 111 | export async function getNormalActorsAsTools( 112 | actors: string[], 113 | apifyToken: string, 114 | ): Promise { 115 | const tools: ToolEntry[] = []; 116 | const actorsToLoad: string[] = []; 117 | for (const actorID of actors) { 118 | const cacheEntry = normalActorToolsCache.get(actorID); 119 | if (cacheEntry && cacheEntry.expiresAt > Date.now()) { 120 | tools.push(cacheEntry.tool); 121 | } else { 122 | actorsToLoad.push(actorID); 123 | } 124 | } 125 | if (actorsToLoad.length === 0) { 126 | return tools; 127 | } 128 | 129 | const getActorDefinitionWithToken = async (actorId: string) => { 130 | return await getActorDefinition(actorId, apifyToken); 131 | }; 132 | const results = await Promise.all(actorsToLoad.map(getActorDefinitionWithToken)); 133 | 134 | // Zip the results with their corresponding actorIDs 135 | for (let i = 0; i < results.length; i++) { 136 | const result = results[i]; 137 | // We need to get the orignal input from the user 138 | // sonce the user can input real Actor ID like '3ox4R101TgZz67sLr' instead of 139 | // 'username/actorName' even though we encourage that. 140 | // And the getActorDefinition does not return the original input it received, just the actorFullName or actorID 141 | const actorIDOrName = actorsToLoad[i]; 142 | 143 | if (result) { 144 | if (result.input && 'properties' in result.input && result.input) { 145 | result.input.properties = markInputPropertiesAsRequired(result.input); 146 | result.input.properties = buildNestedProperties(result.input.properties); 147 | result.input.properties = filterSchemaProperties(result.input.properties); 148 | result.input.properties = shortenProperties(result.input.properties); 149 | result.input.properties = addEnumsToDescriptionsWithExamples(result.input.properties); 150 | } 151 | try { 152 | const memoryMbytes = result.defaultRunOptions?.memoryMbytes || ACTOR_MAX_MEMORY_MBYTES; 153 | const tool: ToolEntry = { 154 | type: 'actor', 155 | tool: { 156 | name: actorNameToToolName(result.actorFullName), 157 | actorFullName: result.actorFullName, 158 | description: `${result.description} Instructions: ${ACTOR_ADDITIONAL_INSTRUCTIONS}`, 159 | inputSchema: result.input || {}, 160 | ajvValidate: ajv.compile(result.input || {}), 161 | memoryMbytes: memoryMbytes > ACTOR_MAX_MEMORY_MBYTES ? ACTOR_MAX_MEMORY_MBYTES : memoryMbytes, 162 | }, 163 | }; 164 | tools.push(tool); 165 | normalActorToolsCache.add(actorIDOrName, { 166 | tool, 167 | expiresAt: Date.now() + TOOL_CACHE_TTL_SECS * 1000, 168 | }); 169 | } catch (validationError) { 170 | log.error(`Failed to compile AJV schema for Actor: ${result.actorFullName}. Error: ${validationError}`); 171 | } 172 | } 173 | } 174 | return tools; 175 | } 176 | 177 | async function getMCPServersAsTools( 178 | actors: string[], 179 | apifyToken: string, 180 | ): Promise { 181 | const actorsMCPServerTools: ToolEntry[] = []; 182 | for (const actorID of actors) { 183 | const serverUrl = await getActorsMCPServerURL(actorID, apifyToken); 184 | log.info(`ActorID: ${actorID} MCP server URL: ${serverUrl}`); 185 | 186 | let client: Client | undefined; 187 | try { 188 | client = await createMCPClient(serverUrl, apifyToken); 189 | const serverTools = await getMCPServerTools(actorID, client, serverUrl); 190 | actorsMCPServerTools.push(...serverTools); 191 | } finally { 192 | if (client) await client.close(); 193 | } 194 | } 195 | 196 | return actorsMCPServerTools; 197 | } 198 | 199 | export async function getActorsAsTools( 200 | actors: string[], 201 | apifyToken: string, 202 | ): Promise { 203 | log.debug(`Fetching actors as tools...`); 204 | log.debug(`Actors: ${actors}`); 205 | // Actorized MCP servers 206 | const actorsMCPServers: string[] = []; 207 | for (const actorID of actors) { 208 | // TODO: rework, we are fetching actor definition from API twice - in the getMCPServerTools 209 | if (await isActorMCPServer(actorID, apifyToken)) { 210 | actorsMCPServers.push(actorID); 211 | } 212 | } 213 | // Normal Actors as a tool 214 | const toolActors = actors.filter((actorID) => !actorsMCPServers.includes(actorID)); 215 | log.debug(`actorsMCPserver: ${actorsMCPServers}`); 216 | log.debug(`toolActors: ${toolActors}`); 217 | 218 | // Normal Actors as a tool 219 | const normalTools = await getNormalActorsAsTools(toolActors, apifyToken); 220 | 221 | // Tools from Actorized MCP servers 222 | const mcpServerTools = await getMCPServersAsTools(actorsMCPServers, apifyToken); 223 | 224 | return [...normalTools, ...mcpServerTools]; 225 | } 226 | 227 | const getActorArgs = z.object({ 228 | actorId: z.string() 229 | .min(1) 230 | .describe('Actor ID or a tilde-separated owner\'s username and Actor name.'), 231 | }); 232 | 233 | /** 234 | * https://docs.apify.com/api/v2/act-get 235 | */ 236 | export const getActor: ToolEntry = { 237 | type: 'internal', 238 | tool: { 239 | name: HelperTools.ACTOR_GET, 240 | actorFullName: HelperTools.ACTOR_GET, 241 | description: 'Gets an object that contains all the details about a specific Actor.' 242 | + 'Actor basic information (ID, name, owner, description)' 243 | + 'Statistics (number of runs, users, etc.)' 244 | + 'Available versions, and configuration details' 245 | + 'Use Actor ID or Actor full name, separated by tilde username~name.', 246 | inputSchema: zodToJsonSchema(getActorArgs), 247 | ajvValidate: ajv.compile(zodToJsonSchema(getActorArgs)), 248 | call: async (toolArgs) => { 249 | const { args, apifyToken } = toolArgs; 250 | const { actorId } = getActorArgs.parse(args); 251 | const client = new ApifyClient({ token: apifyToken }); 252 | // Get Actor - contains a lot of irrelevant information 253 | const actor = await client.actor(actorId).get(); 254 | if (!actor) { 255 | return { content: [{ type: 'text', text: `Actor '${actorId}' not found.` }] }; 256 | } 257 | return { content: [{ type: 'text', text: JSON.stringify(actor) }] }; 258 | }, 259 | } as InternalTool, 260 | }; 261 | -------------------------------------------------------------------------------- /src/tools/build.ts: -------------------------------------------------------------------------------- 1 | import { Ajv } from 'ajv'; 2 | import { z } from 'zod'; 3 | import zodToJsonSchema from 'zod-to-json-schema'; 4 | 5 | import log from '@apify/log'; 6 | 7 | import { ApifyClient } from '../apify-client.js'; 8 | import { ACTOR_README_MAX_LENGTH, HelperTools } from '../const.js'; 9 | import type { 10 | ActorDefinitionPruned, 11 | ActorDefinitionWithDesc, 12 | InternalTool, 13 | ISchemaProperties, 14 | ToolEntry, 15 | } from '../types.js'; 16 | import { filterSchemaProperties, shortenProperties } from './utils.js'; 17 | 18 | const ajv = new Ajv({ coerceTypes: 'array', strict: false }); 19 | 20 | /** 21 | * Get Actor input schema by Actor name. 22 | * First, fetch the Actor details to get the default build tag and buildId. 23 | * Then, fetch the build details and return actorName, description, and input schema. 24 | * @param {string} actorIdOrName - Actor ID or Actor full name. 25 | * @param {number} limit - Truncate the README to this limit. 26 | * @param {string} apifyToken 27 | * @returns {Promise} - The actor definition with description or null if not found. 28 | */ 29 | export async function getActorDefinition( 30 | actorIdOrName: string, 31 | apifyToken: string, 32 | limit: number = ACTOR_README_MAX_LENGTH, 33 | ): Promise { 34 | const client = new ApifyClient({ token: apifyToken }); 35 | const actorClient = client.actor(actorIdOrName); 36 | try { 37 | // Fetch actor details 38 | const actor = await actorClient.get(); 39 | if (!actor) { 40 | log.error(`Failed to fetch input schema for Actor: ${actorIdOrName}. Actor not found.`); 41 | return null; 42 | } 43 | 44 | const defaultBuildClient = await actorClient.defaultBuild(); 45 | const buildDetails = await defaultBuildClient.get(); 46 | 47 | if (buildDetails?.actorDefinition) { 48 | const actorDefinitions = buildDetails?.actorDefinition as ActorDefinitionWithDesc; 49 | actorDefinitions.id = actor.id; 50 | actorDefinitions.readme = truncateActorReadme(actorDefinitions.readme || '', limit); 51 | actorDefinitions.description = actor.description || ''; 52 | actorDefinitions.actorFullName = `${actor.username}/${actor.name}`; 53 | actorDefinitions.defaultRunOptions = actor.defaultRunOptions; 54 | return pruneActorDefinition(actorDefinitions); 55 | } 56 | return null; 57 | } catch (error) { 58 | const errorMessage = `Failed to fetch input schema for Actor: ${actorIdOrName} with error ${error}.`; 59 | log.error(errorMessage); 60 | throw new Error(errorMessage); 61 | } 62 | } 63 | function pruneActorDefinition(response: ActorDefinitionWithDesc): ActorDefinitionPruned { 64 | return { 65 | id: response.id, 66 | actorFullName: response.actorFullName || '', 67 | buildTag: response?.buildTag || '', 68 | readme: response?.readme || '', 69 | input: response?.input && 'type' in response.input && 'properties' in response.input 70 | ? { 71 | ...response.input, 72 | type: response.input.type as string, 73 | properties: response.input.properties as Record, 74 | } 75 | : undefined, 76 | description: response.description, 77 | defaultRunOptions: response.defaultRunOptions, 78 | }; 79 | } 80 | /** Prune Actor README if it is too long 81 | * If the README is too long 82 | * - We keep the README as it is up to the limit. 83 | * - After the limit, we keep heading only 84 | * - We add a note that the README was truncated because it was too long. 85 | */ 86 | function truncateActorReadme(readme: string, limit = ACTOR_README_MAX_LENGTH): string { 87 | if (readme.length <= limit) { 88 | return readme; 89 | } 90 | const readmeFirst = readme.slice(0, limit); 91 | const readmeRest = readme.slice(limit); 92 | const lines = readmeRest.split('\n'); 93 | const prunedReadme = lines.filter((line) => line.startsWith('#')); 94 | return `${readmeFirst}\n\nREADME was truncated because it was too long. Remaining headers:\n${prunedReadme.join(', ')}`; 95 | } 96 | 97 | const getActorDefinitionArgsSchema = z.object({ 98 | actorName: z.string() 99 | .min(1) 100 | .describe('Retrieve input, readme, and other details for Actor ID or Actor full name. ' 101 | + 'Actor name is always composed from `username/name`'), 102 | limit: z.number() 103 | .int() 104 | .default(ACTOR_README_MAX_LENGTH) 105 | .describe(`Truncate the README to this limit. Default value is ${ACTOR_README_MAX_LENGTH}.`), 106 | }); 107 | 108 | /** 109 | * https://docs.apify.com/api/v2/actor-build-get 110 | */ 111 | export const actorDefinitionTool: ToolEntry = { 112 | type: 'internal', 113 | tool: { 114 | name: HelperTools.ACTOR_GET_DETAILS, 115 | // TODO: remove actorFullName from internal tools 116 | actorFullName: HelperTools.ACTOR_GET_DETAILS, 117 | description: 'Get documentation, readme, input schema and other details about an Actor. ' 118 | + 'For example, when user says, I need to know more about web crawler Actor.' 119 | + 'Get details for an Actor with with Actor ID or Actor full name, i.e. username/name.' 120 | + `Limit the length of the README if needed.`, 121 | inputSchema: zodToJsonSchema(getActorDefinitionArgsSchema), 122 | ajvValidate: ajv.compile(zodToJsonSchema(getActorDefinitionArgsSchema)), 123 | call: async (toolArgs) => { 124 | const { args, apifyToken } = toolArgs; 125 | 126 | const parsed = getActorDefinitionArgsSchema.parse(args); 127 | const v = await getActorDefinition(parsed.actorName, apifyToken, parsed.limit); 128 | if (!v) { 129 | return { content: [{ type: 'text', text: `Actor '${parsed.actorName}' not found.` }] }; 130 | } 131 | if (v && v.input && 'properties' in v.input && v.input) { 132 | const properties = filterSchemaProperties(v.input.properties as { [key: string]: ISchemaProperties }); 133 | v.input.properties = shortenProperties(properties); 134 | } 135 | return { content: [{ type: 'text', text: JSON.stringify(v) }] }; 136 | }, 137 | } as InternalTool, 138 | }; 139 | -------------------------------------------------------------------------------- /src/tools/dataset.ts: -------------------------------------------------------------------------------- 1 | import { Ajv } from 'ajv'; 2 | import { z } from 'zod'; 3 | import zodToJsonSchema from 'zod-to-json-schema'; 4 | 5 | import { ApifyClient } from '../apify-client.js'; 6 | import { HelperTools } from '../const.js'; 7 | import type { InternalTool, ToolEntry } from '../types.js'; 8 | 9 | const ajv = new Ajv({ coerceTypes: 'array', strict: false }); 10 | 11 | const getDatasetArgs = z.object({ 12 | datasetId: z.string() 13 | .min(1) 14 | .describe('Dataset ID or username~dataset-name.'), 15 | }); 16 | 17 | const getDatasetItemsArgs = z.object({ 18 | datasetId: z.string() 19 | .min(1) 20 | .describe('Dataset ID or username~dataset-name.'), 21 | clean: z.boolean().optional() 22 | .describe('If true, returns only non-empty items and skips hidden fields (starting with #). Shortcut for skipHidden=true and skipEmpty=true.'), 23 | offset: z.number().optional() 24 | .describe('Number of items to skip at the start. Default is 0.'), 25 | limit: z.number().optional() 26 | .describe('Maximum number of items to return. No limit by default.'), 27 | fields: z.string().optional() 28 | .describe('Comma-separated list of fields to include in results. ' 29 | + 'Fields in output are sorted as specified. ' 30 | + 'For nested objects, use dot notation (e.g. "metadata.url") after flattening.'), 31 | omit: z.string().optional() 32 | .describe('Comma-separated list of fields to exclude from results.'), 33 | desc: z.boolean().optional() 34 | .describe('If true, results are returned in reverse order (newest to oldest).'), 35 | flatten: z.string().optional() 36 | .describe('Comma-separated list of fields which should transform nested objects into flat structures. ' 37 | + 'For example, with flatten="metadata" the object {"metadata":{"url":"hello"}} becomes {"metadata.url":"hello"}. ' 38 | + 'This is required before accessing nested fields with the fields parameter.'), 39 | }); 40 | 41 | /** 42 | * https://docs.apify.com/api/v2/dataset-get 43 | */ 44 | export const getDataset: ToolEntry = { 45 | type: 'internal', 46 | tool: { 47 | name: HelperTools.DATASET_GET, 48 | actorFullName: HelperTools.DATASET_GET, 49 | description: 'Dataset is a collection of structured data created by an Actor run. ' 50 | + 'Returns information about dataset object with metadata (itemCount, schema, fields, stats). ' 51 | + `Fields describe the structure of the dataset and can be used to filter the data with the ${HelperTools.DATASET_GET_ITEMS} tool. ` 52 | + 'Note: itemCount updates may have 5s delay.' 53 | + 'The dataset can be accessed with the dataset URL: GET: https://api.apify.com/v2/datasets/:datasetId', 54 | inputSchema: zodToJsonSchema(getDatasetArgs), 55 | ajvValidate: ajv.compile(zodToJsonSchema(getDatasetArgs)), 56 | call: async (toolArgs) => { 57 | const { args, apifyToken } = toolArgs; 58 | const parsed = getDatasetArgs.parse(args); 59 | const client = new ApifyClient({ token: apifyToken }); 60 | const v = await client.dataset(parsed.datasetId).get(); 61 | if (!v) { 62 | return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] }; 63 | } 64 | return { content: [{ type: 'text', text: JSON.stringify(v) }] }; 65 | }, 66 | } as InternalTool, 67 | }; 68 | 69 | /** 70 | * https://docs.apify.com/api/v2/dataset-items-get 71 | */ 72 | export const getDatasetItems: ToolEntry = { 73 | type: 'internal', 74 | tool: { 75 | name: HelperTools.DATASET_GET_ITEMS, 76 | actorFullName: HelperTools.DATASET_GET_ITEMS, 77 | description: 'Returns dataset items with pagination support. ' 78 | + 'Items can be sorted (newest to oldest) and filtered (clean mode skips empty items and hidden fields). ' 79 | + 'Supports field selection - include specific fields or exclude unwanted ones using comma-separated lists. ' 80 | + 'For nested objects, you must first flatten them using the flatten parameter before accessing their fields. ' 81 | + 'Example: To get URLs from items like [{"metadata":{"url":"example.com"}}], ' 82 | + 'use flatten="metadata" and then fields="metadata.url". ' 83 | + 'The flattening transforms nested objects into dot-notation format ' 84 | + '(e.g. {"metadata":{"url":"x"}} becomes {"metadata.url":"x"}). ' 85 | + 'Retrieve only the fields you need, reducing the response size and improving performance. ' 86 | + 'The response includes total count, offset, limit, and items array.', 87 | inputSchema: zodToJsonSchema(getDatasetItemsArgs), 88 | ajvValidate: ajv.compile(zodToJsonSchema(getDatasetItemsArgs)), 89 | call: async (toolArgs) => { 90 | const { args, apifyToken } = toolArgs; 91 | const parsed = getDatasetItemsArgs.parse(args); 92 | const client = new ApifyClient({ token: apifyToken }); 93 | 94 | // Convert comma-separated strings to arrays 95 | const fields = parsed.fields?.split(',').map((f) => f.trim()); 96 | const omit = parsed.omit?.split(',').map((f) => f.trim()); 97 | const flatten = parsed.flatten?.split(',').map((f) => f.trim()); 98 | 99 | const v = await client.dataset(parsed.datasetId).listItems({ 100 | clean: parsed.clean, 101 | offset: parsed.offset, 102 | limit: parsed.limit, 103 | fields, 104 | omit, 105 | desc: parsed.desc, 106 | flatten, 107 | }); 108 | if (!v) { 109 | return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] }; 110 | } 111 | return { content: [{ type: 'text', text: JSON.stringify(v) }] }; 112 | }, 113 | } as InternalTool, 114 | }; 115 | -------------------------------------------------------------------------------- /src/tools/dataset_collection.ts: -------------------------------------------------------------------------------- 1 | import { Ajv } from 'ajv'; 2 | import { z } from 'zod'; 3 | import zodToJsonSchema from 'zod-to-json-schema'; 4 | 5 | import { ApifyClient } from '../apify-client.js'; 6 | import { HelperTools } from '../const.js'; 7 | import type { InternalTool, ToolEntry } from '../types.js'; 8 | 9 | const ajv = new Ajv({ coerceTypes: 'array', strict: false }); 10 | 11 | const getUserDatasetsListArgs = z.object({ 12 | offset: z.number() 13 | .describe('Number of array elements that should be skipped at the start. The default value is 0.') 14 | .default(0), 15 | limit: z.number() 16 | .max(20) 17 | .describe('Maximum number of array elements to return. The default value (as well as the maximum) is 20.') 18 | .default(10), 19 | desc: z.boolean() 20 | .describe('If true or 1 then the datasets are sorted by the createdAt field in descending order. Default: sorted in ascending order.') 21 | .default(false), 22 | unnamed: z.boolean() 23 | .describe('If true or 1 then all the datasets are returned. By default only named datasets are returned.') 24 | .default(false), 25 | }); 26 | 27 | /** 28 | * https://docs.apify.com/api/v2/datasets-get 29 | */ 30 | export const getUserDatasetsList: ToolEntry = { 31 | type: 'internal', 32 | tool: { 33 | name: HelperTools.DATASET_LIST_GET, 34 | actorFullName: HelperTools.DATASET_LIST_GET, 35 | description: 'Lists datasets (collections of Actor run data). ' 36 | + 'Actor runs automatically produce unnamed datasets (use unnamed=true to include these). ' 37 | + 'Users can also create named datasets manually. ' 38 | + 'Each dataset includes itemCount, access settings, and usage stats (readCount, writeCount). ' 39 | + 'Results are sorted by createdAt in ascending order (use desc=true for descending). ' 40 | + 'Supports pagination with limit (max 20) and offset parameters.', 41 | inputSchema: zodToJsonSchema(getUserDatasetsListArgs), 42 | ajvValidate: ajv.compile(zodToJsonSchema(getUserDatasetsListArgs)), 43 | call: async (toolArgs) => { 44 | const { args, apifyToken } = toolArgs; 45 | const parsed = getUserDatasetsListArgs.parse(args); 46 | const client = new ApifyClient({ token: apifyToken }); 47 | const datasets = await client.datasets().list({ 48 | limit: parsed.limit, 49 | offset: parsed.offset, 50 | desc: parsed.desc, 51 | unnamed: parsed.unnamed, 52 | }); 53 | return { content: [{ type: 'text', text: JSON.stringify(datasets) }] }; 54 | }, 55 | } as InternalTool, 56 | }; 57 | -------------------------------------------------------------------------------- /src/tools/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Ajv } from 'ajv'; 2 | import { z } from 'zod'; 3 | import zodToJsonSchema from 'zod-to-json-schema'; 4 | 5 | import { HelperTools } from '../const.js'; 6 | import type { InternalTool, ToolEntry } from '../types'; 7 | import { getActorsAsTools } from './actor.js'; 8 | import { actorNameToToolName } from './utils.js'; 9 | 10 | const ajv = new Ajv({ coerceTypes: 'array', strict: false }); 11 | 12 | const APIFY_MCP_HELP_TOOL_TEXT = `Apify MCP server help: 13 | 14 | Note: "MCP" stands for "Model Context Protocol". The user can use the "RAG Web Browser" tool to get the content of the links mentioned in this help and present it to the user. 15 | 16 | This MCP server can be used in the following ways: 17 | - Locally over "STDIO". 18 | - Remotely over "SSE" or streamable "HTTP" transport with the "Actors MCP Server Apify Actor". 19 | - Remotely over "SSE" or streamable "HTTP" transport with "https://mcp.apify.com". 20 | 21 | # Usage 22 | ## Locally over "STDIO" 23 | 1. The user should install the "@apify/actors-mcp-server" NPM package. 24 | 2. The user should configure the MCP client to use the MCP server. Refer to "https://github.com/apify/actors-mcp-server" or the MCP client documentation for more details (the user can specify which MCP client is being used). 25 | The user needs to set the following environment variables: 26 | - "APIFY_TOKEN": Apify token to authenticate with the MCP server. 27 | If the user wants to load an Actor outside the default ones, the user needs to pass it as a CLI argument: 28 | - "--actors " // comma-separated list of Actor names, for example, "apify/rag-web-browser,apify/instagram-scraper". 29 | If the user wants to enable the dynamic addition of Actors to the MCP server, the user needs to pass the following CLI argument: 30 | - "--enable-adding-actors". 31 | 32 | ## Remotely over "SSE" or streamable "HTTP" transport with "Actors MCP Server Apify Actor" 33 | 1. The user should configure the MCP client to use the "Actors MCP Server Apify Actor" with: 34 | - "SSE" transport URL: "https://actors-mcp-server.apify.actor/sse". 35 | - Streamable "HTTP" transport URL: "https://actors-mcp-server.apify.actor/mcp". 36 | 2. The user needs to pass an "APIFY_TOKEN" as a URL query parameter "?token=" or set the following headers: "Authorization: Bearer ". 37 | If the user wants to load an Actor outside the default ones, the user needs to pass it as a URL query parameter: 38 | - "?actors=" // comma-separated list of Actor names, for example, "apify/rag-web-browser,apify/instagram-scraper". 39 | If the user wants to enable the addition of Actors to the MCP server dynamically, the user needs to pass the following URL query parameter: 40 | - "?enable-adding-actors=true". 41 | 42 | ## Remotely over "SSE" or streamable "HTTP" transport with "https://mcp.apify.com" 43 | 1. The user should configure the MCP client to use "https://mcp.apify.com" with: 44 | - "SSE" transport URL: "https://mcp.apify.com/sse". 45 | - Streamable "HTTP" transport URL: "https://mcp.apify.com/". 46 | 2. The user needs to pass an "APIFY_TOKEN" as a URL query parameter "?token=" or set the following headers: "Authorization: Bearer ". 47 | If the user wants to load an Actor outside the default ones, the user needs to pass it as a URL query parameter: 48 | - "?actors=" // comma-separated list of Actor names, for example, "apify/rag-web-browser,apify/instagram-scraper". 49 | If the user wants to enable the addition of Actors to the MCP server dynamically, the user needs to pass the following URL query parameter: 50 | - "?enable-adding-actors=true". 51 | 52 | # Features 53 | ## Dynamic adding of Actors 54 | THIS FEATURE MAY NOT BE SUPPORTED BY ALL MCP CLIENTS. THE USER MUST ENSURE THAT THE CLIENT SUPPORTS IT! 55 | To enable this feature, see the usage section. Once dynamic adding is enabled, tools will be added that allow the user to add or remove Actors from the MCP server. 56 | Tools related: 57 | - "add-actor". 58 | - "remove-actor". 59 | If the user is using these tools and it seems like the tools have been added but cannot be called, the issue may be that the client does not support dynamic adding of Actors. 60 | In that case, the user should check the MCP client documentation to see if the client supports this feature. 61 | `; 62 | 63 | export const addToolArgsSchema = z.object({ 64 | actorName: z.string() 65 | .min(1) 66 | .describe('Add a tool, Actor or MCP-Server to available tools by Actor ID or tool full name.' 67 | + 'Tool name is always composed from `username/name`'), 68 | }); 69 | export const addTool: ToolEntry = { 70 | type: 'internal', 71 | tool: { 72 | name: HelperTools.ACTOR_ADD, 73 | description: 'Add a tool, Actor or MCP-Server to available tools by Actor ID or Actor name. ' 74 | + 'A tool is an Actor or MCP-Server that can be called by the user' 75 | + 'Do not execute the tool, only add it and list it in available tools. ' 76 | + 'For example, add a tool with username/name when user wants to scrape data from a website.', 77 | inputSchema: zodToJsonSchema(addToolArgsSchema), 78 | ajvValidate: ajv.compile(zodToJsonSchema(addToolArgsSchema)), 79 | // TODO: I don't like that we are passing apifyMcpServer and mcpServer to the tool 80 | call: async (toolArgs) => { 81 | const { apifyMcpServer, mcpServer, apifyToken, args } = toolArgs; 82 | const parsed = addToolArgsSchema.parse(args); 83 | if (apifyMcpServer.listAllToolNames().includes(parsed.actorName)) { 84 | return { 85 | content: [{ 86 | type: 'text', 87 | text: `Actor ${parsed.actorName} is already available. No new tools were added.`, 88 | }], 89 | }; 90 | } 91 | const tools = await getActorsAsTools([parsed.actorName], apifyToken); 92 | const toolsAdded = apifyMcpServer.upsertTools(tools, true); 93 | await mcpServer.notification({ method: 'notifications/tools/list_changed' }); 94 | 95 | return { 96 | content: [{ 97 | type: 'text', 98 | text: `Actor ${parsed.actorName} has been added. Newly available tools: ${ 99 | toolsAdded.map( 100 | (t) => `${t.tool.name}`, 101 | ).join(', ') 102 | }.`, 103 | }], 104 | }; 105 | }, 106 | } as InternalTool, 107 | }; 108 | export const removeToolArgsSchema = z.object({ 109 | toolName: z.string() 110 | .min(1) 111 | .describe('Tool name to remove from available tools.') 112 | .transform((val) => actorNameToToolName(val)), 113 | }); 114 | export const removeTool: ToolEntry = { 115 | type: 'internal', 116 | tool: { 117 | name: HelperTools.ACTOR_REMOVE, 118 | description: 'Remove a tool, an Actor or MCP-Server by name from available tools. ' 119 | + 'For example, when user says, I do not need a tool username/name anymore', 120 | inputSchema: zodToJsonSchema(removeToolArgsSchema), 121 | ajvValidate: ajv.compile(zodToJsonSchema(removeToolArgsSchema)), 122 | // TODO: I don't like that we are passing apifyMcpServer and mcpServer to the tool 123 | call: async (toolArgs) => { 124 | const { apifyMcpServer, mcpServer, args } = toolArgs; 125 | const parsed = removeToolArgsSchema.parse(args); 126 | // Check if tool exists before attempting removal 127 | if (!apifyMcpServer.tools.has(parsed.toolName)) { 128 | // Send notification so client can update its tool list 129 | // just in case the client tool list is out of sync 130 | await mcpServer.notification({ method: 'notifications/tools/list_changed' }); 131 | return { 132 | content: [{ 133 | type: 'text', 134 | text: `Tool '${parsed.toolName}' not found. No tools were removed.`, 135 | }], 136 | }; 137 | } 138 | const removedTools = apifyMcpServer.removeToolsByName([parsed.toolName], true); 139 | await mcpServer.notification({ method: 'notifications/tools/list_changed' }); 140 | return { content: [{ type: 'text', text: `Tools removed: ${removedTools.join(', ')}` }] }; 141 | }, 142 | } as InternalTool, 143 | }; 144 | 145 | // Tool takes no arguments 146 | export const helpToolArgsSchema = z.object({}); 147 | export const helpTool: ToolEntry = { 148 | type: 'internal', 149 | tool: { 150 | name: HelperTools.APIFY_MCP_HELP_TOOL, 151 | description: 'Helper tool to get information on how to use and troubleshoot the Apify MCP server. ' 152 | + 'This tool always returns the same help message with information about the server and how to use it. ' 153 | + 'Call this tool in case of any problems or uncertainties with the server. ', 154 | inputSchema: zodToJsonSchema(helpToolArgsSchema), 155 | ajvValidate: ajv.compile(zodToJsonSchema(helpToolArgsSchema)), 156 | call: async () => { 157 | return { content: [{ type: 'text', text: APIFY_MCP_HELP_TOOL_TEXT }] }; 158 | }, 159 | } as InternalTool, 160 | }; 161 | -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- 1 | // Import specific tools that are being used 2 | import { callActorGetDataset, getActor, getActorsAsTools } from './actor.js'; 3 | import { actorDefinitionTool } from './build.js'; 4 | import { getDataset, getDatasetItems } from './dataset.js'; 5 | import { getUserDatasetsList } from './dataset_collection.js'; 6 | import { addTool, helpTool, removeTool } from './helpers.js'; 7 | import { getKeyValueStore, getKeyValueStoreKeys, getKeyValueStoreRecord } from './key_value_store.js'; 8 | import { getUserKeyValueStoresList } from './key_value_store_collection.js'; 9 | import { abortActorRun, getActorLog, getActorRun } from './run.js'; 10 | import { getUserRunsList } from './run_collection.js'; 11 | import { searchActors } from './store_collection.js'; 12 | 13 | export const defaultTools = [ 14 | abortActorRun, 15 | actorDefinitionTool, 16 | getActor, 17 | getActorLog, 18 | getActorRun, 19 | getDataset, 20 | getDatasetItems, 21 | getKeyValueStore, 22 | getKeyValueStoreKeys, 23 | getKeyValueStoreRecord, 24 | getUserRunsList, 25 | getUserDatasetsList, 26 | getUserKeyValueStoresList, 27 | helpTool, 28 | searchActors, 29 | ]; 30 | 31 | export const addRemoveTools = [ 32 | addTool, 33 | removeTool, 34 | ]; 35 | 36 | // Export only the tools that are being used 37 | export { 38 | addTool, 39 | removeTool, 40 | getActorsAsTools, 41 | callActorGetDataset, 42 | }; 43 | -------------------------------------------------------------------------------- /src/tools/key_value_store.ts: -------------------------------------------------------------------------------- 1 | import { Ajv } from 'ajv'; 2 | import { z } from 'zod'; 3 | import zodToJsonSchema from 'zod-to-json-schema'; 4 | 5 | import { ApifyClient } from '../apify-client.js'; 6 | import { HelperTools } from '../const.js'; 7 | import type { InternalTool, ToolEntry } from '../types.js'; 8 | 9 | const ajv = new Ajv({ coerceTypes: 'array', strict: false }); 10 | 11 | const getKeyValueStoreArgs = z.object({ 12 | storeId: z.string() 13 | .min(1) 14 | .describe('Key-value store ID or username~store-name'), 15 | }); 16 | 17 | /** 18 | * https://docs.apify.com/api/v2/key-value-store-get 19 | */ 20 | export const getKeyValueStore: ToolEntry = { 21 | type: 'internal', 22 | tool: { 23 | name: HelperTools.KEY_VALUE_STORE_GET, 24 | actorFullName: HelperTools.KEY_VALUE_STORE_GET, 25 | description: 'Gets an object that contains all the details about a specific key-value store. ' 26 | + 'Returns store metadata including ID, name, owner, access settings, and usage statistics. ' 27 | + 'Use store ID or username~store-name format to identify the store.', 28 | inputSchema: zodToJsonSchema(getKeyValueStoreArgs), 29 | ajvValidate: ajv.compile(zodToJsonSchema(getKeyValueStoreArgs)), 30 | call: async (toolArgs) => { 31 | const { args, apifyToken } = toolArgs; 32 | const parsed = getKeyValueStoreArgs.parse(args); 33 | const client = new ApifyClient({ token: apifyToken }); 34 | const store = await client.keyValueStore(parsed.storeId).get(); 35 | return { content: [{ type: 'text', text: JSON.stringify(store) }] }; 36 | }, 37 | } as InternalTool, 38 | }; 39 | 40 | const getKeyValueStoreKeysArgs = z.object({ 41 | storeId: z.string() 42 | .min(1) 43 | .describe('Key-value store ID or username~store-name'), 44 | exclusiveStartKey: z.string() 45 | .optional() 46 | .describe('All keys up to this one (including) are skipped from the result.'), 47 | limit: z.number() 48 | .max(10) 49 | .optional() 50 | .describe('Number of keys to be returned. Maximum value is 1000.'), 51 | }); 52 | 53 | /** 54 | * https://docs.apify.com/api/v2/key-value-store-keys-get 55 | */ 56 | export const getKeyValueStoreKeys: ToolEntry = { 57 | type: 'internal', 58 | tool: { 59 | name: HelperTools.KEY_VALUE_STORE_KEYS_GET, 60 | actorFullName: HelperTools.KEY_VALUE_STORE_KEYS_GET, 61 | description: 'Returns a list of objects describing keys of a given key-value store, ' 62 | + 'as well as some information about the values (e.g. size). ' 63 | + 'Supports pagination using exclusiveStartKey and limit parameters. ' 64 | + 'Use store ID or username~store-name format to identify the store.', 65 | inputSchema: zodToJsonSchema(getKeyValueStoreKeysArgs), 66 | ajvValidate: ajv.compile(zodToJsonSchema(getKeyValueStoreKeysArgs)), 67 | call: async (toolArgs) => { 68 | const { args, apifyToken } = toolArgs; 69 | const parsed = getKeyValueStoreKeysArgs.parse(args); 70 | const client = new ApifyClient({ token: apifyToken }); 71 | const keys = await client.keyValueStore(parsed.storeId).listKeys({ 72 | exclusiveStartKey: parsed.exclusiveStartKey, 73 | limit: parsed.limit, 74 | }); 75 | return { content: [{ type: 'text', text: JSON.stringify(keys) }] }; 76 | }, 77 | } as InternalTool, 78 | }; 79 | 80 | const getKeyValueStoreRecordArgs = z.object({ 81 | storeId: z.string() 82 | .min(1) 83 | .describe('Key-value store ID or username~store-name'), 84 | recordKey: z.string() 85 | .min(1) 86 | .describe('Key of the record to retrieve.'), 87 | }); 88 | 89 | /** 90 | * https://docs.apify.com/api/v2/key-value-store-record-get 91 | */ 92 | export const getKeyValueStoreRecord: ToolEntry = { 93 | type: 'internal', 94 | tool: { 95 | name: HelperTools.KEY_VALUE_STORE_RECORD_GET, 96 | actorFullName: HelperTools.KEY_VALUE_STORE_RECORD_GET, 97 | description: 'Gets a value stored in the key-value store under a specific key. ' 98 | + 'The response maintains the original Content-Encoding of the stored value. ' 99 | + 'If the request does not specify the correct Accept-Encoding header, the record will be decompressed. ' 100 | + 'Most HTTP clients handle decompression automatically.' 101 | + 'The record can be accessed with the URL: GET: https://api.apify.com/v2/key-value-stores/:storeId/records/:recordKey', 102 | inputSchema: zodToJsonSchema(getKeyValueStoreRecordArgs), 103 | ajvValidate: ajv.compile(zodToJsonSchema(getKeyValueStoreRecordArgs)), 104 | call: async (toolArgs) => { 105 | const { args, apifyToken } = toolArgs; 106 | const parsed = getKeyValueStoreRecordArgs.parse(args); 107 | const client = new ApifyClient({ token: apifyToken }); 108 | const record = await client.keyValueStore(parsed.storeId).getRecord(parsed.recordKey); 109 | return { content: [{ type: 'text', text: JSON.stringify(record) }] }; 110 | }, 111 | } as InternalTool, 112 | }; 113 | -------------------------------------------------------------------------------- /src/tools/key_value_store_collection.ts: -------------------------------------------------------------------------------- 1 | import { Ajv } from 'ajv'; 2 | import { z } from 'zod'; 3 | import zodToJsonSchema from 'zod-to-json-schema'; 4 | 5 | import { ApifyClient } from '../apify-client.js'; 6 | import { HelperTools } from '../const.js'; 7 | import type { InternalTool, ToolEntry } from '../types.js'; 8 | 9 | const ajv = new Ajv({ coerceTypes: 'array', strict: false }); 10 | 11 | const getUserKeyValueStoresListArgs = z.object({ 12 | offset: z.number() 13 | .describe('Number of array elements that should be skipped at the start. The default is 0.') 14 | .default(0), 15 | limit: z.number() 16 | .max(10) 17 | .describe('Maximum number of array elements to return. The default value (and maximum) is 10.') 18 | .default(10), 19 | desc: z.boolean() 20 | .describe('If true or 1 then the stores are sorted by the createdAt field in descending order. Default: sorted in ascending order.') 21 | .default(false), 22 | unnamed: z.boolean() 23 | .describe('If true or 1 then all the stores are returned. By default, only named key-value stores are returned.') 24 | .default(false), 25 | }); 26 | 27 | /** 28 | * https://docs.apify.com/api/v2/key-value-stores-get 29 | */ 30 | export const getUserKeyValueStoresList: ToolEntry = { 31 | type: 'internal', 32 | tool: { 33 | name: HelperTools.KEY_VALUE_STORE_LIST_GET, 34 | actorFullName: HelperTools.KEY_VALUE_STORE_LIST_GET, 35 | description: 'Lists key-value stores owned by the user. ' 36 | + 'Actor runs automatically produce unnamed stores (use unnamed=true to include these). ' 37 | + 'Users can also create named stores manually. ' 38 | + 'Each store includes basic information about the store. ' 39 | + 'Results are sorted by createdAt in ascending order (use desc=true for descending). ' 40 | + 'Supports pagination with limit (max 1000) and offset parameters.', 41 | inputSchema: zodToJsonSchema(getUserKeyValueStoresListArgs), 42 | ajvValidate: ajv.compile(zodToJsonSchema(getUserKeyValueStoresListArgs)), 43 | call: async (toolArgs) => { 44 | const { args, apifyToken } = toolArgs; 45 | const parsed = getUserKeyValueStoresListArgs.parse(args); 46 | const client = new ApifyClient({ token: apifyToken }); 47 | const stores = await client.keyValueStores().list({ 48 | limit: parsed.limit, 49 | offset: parsed.offset, 50 | desc: parsed.desc, 51 | unnamed: parsed.unnamed, 52 | }); 53 | return { content: [{ type: 'text', text: JSON.stringify(stores) }] }; 54 | }, 55 | } as InternalTool, 56 | }; 57 | -------------------------------------------------------------------------------- /src/tools/run.ts: -------------------------------------------------------------------------------- 1 | import { Ajv } from 'ajv'; 2 | import { z } from 'zod'; 3 | import zodToJsonSchema from 'zod-to-json-schema'; 4 | 5 | import { ApifyClient } from '../apify-client.js'; 6 | import { HelperTools } from '../const.js'; 7 | import type { InternalTool, ToolEntry } from '../types.js'; 8 | 9 | const ajv = new Ajv({ coerceTypes: 'array', strict: false }); 10 | 11 | const getActorRunArgs = z.object({ 12 | runId: z.string() 13 | .min(1) 14 | .describe('The ID of the Actor run.'), 15 | }); 16 | 17 | const abortRunArgs = z.object({ 18 | runId: z.string() 19 | .min(1) 20 | .describe('The ID of the Actor run to abort.'), 21 | gracefully: z.boolean().optional().describe('If true, the Actor run will abort gracefully with a 30-second timeout.'), 22 | }); 23 | 24 | /** 25 | * https://docs.apify.com/api/v2/actor-run-get 26 | */ 27 | export const getActorRun: ToolEntry = { 28 | type: 'internal', 29 | tool: { 30 | name: HelperTools.ACTOR_RUNS_GET, 31 | actorFullName: HelperTools.ACTOR_RUNS_GET, 32 | description: 'Gets detailed information about a specific Actor run including its status, status message, metrics, and resources. ' 33 | + 'The response includes run metadata (ID, status, status message, timestamps), performance stats (CPU, memory, network), ' 34 | + 'resource IDs (dataset, key-value store, request queue), and configuration options.', 35 | inputSchema: zodToJsonSchema(getActorRunArgs), 36 | ajvValidate: ajv.compile(zodToJsonSchema(getActorRunArgs)), 37 | call: async (toolArgs) => { 38 | const { args, apifyToken } = toolArgs; 39 | const parsed = getActorRunArgs.parse(args); 40 | const client = new ApifyClient({ token: apifyToken }); 41 | const v = await client.run(parsed.runId).get(); 42 | if (!v) { 43 | return { content: [{ type: 'text', text: `Run with ID '${parsed.runId}' not found.` }] }; 44 | } 45 | return { content: [{ type: 'text', text: JSON.stringify(v) }] }; 46 | }, 47 | } as InternalTool, 48 | }; 49 | 50 | const GetRunLogArgs = z.object({ 51 | runId: z.string().describe('The ID of the Actor run.'), 52 | lines: z.number() 53 | .max(50) 54 | .describe('Output the last NUM lines, instead of the last 10') 55 | .default(10), 56 | }); 57 | 58 | /** 59 | * https://docs.apify.com/api/v2/actor-run-get 60 | * /v2/actor-runs/{runId}/log{?token} 61 | */ 62 | export const getActorLog: ToolEntry = { 63 | type: 'internal', 64 | tool: { 65 | name: HelperTools.ACTOR_RUNS_LOG, 66 | actorFullName: HelperTools.ACTOR_RUNS_LOG, 67 | description: 'Retrieves logs for a specific Actor run. ' 68 | + 'Returns the log content as plain text.', 69 | inputSchema: zodToJsonSchema(GetRunLogArgs), 70 | ajvValidate: ajv.compile(zodToJsonSchema(GetRunLogArgs)), 71 | call: async (toolArgs) => { 72 | const { args, apifyToken } = toolArgs; 73 | const parsed = GetRunLogArgs.parse(args); 74 | const client = new ApifyClient({ token: apifyToken }); 75 | const v = await client.run(parsed.runId).log().get() ?? ''; 76 | const lines = v.split('\n'); 77 | const text = lines.slice(lines.length - parsed.lines - 1, lines.length).join('\n'); 78 | return { content: [{ type: 'text', text }] }; 79 | }, 80 | } as InternalTool, 81 | }; 82 | 83 | /** 84 | * https://docs.apify.com/api/v2/actor-run-abort-post 85 | */ 86 | export const abortActorRun: ToolEntry = { 87 | type: 'internal', 88 | tool: { 89 | name: HelperTools.ACTOR_RUNS_ABORT, 90 | actorFullName: HelperTools.ACTOR_RUNS_ABORT, 91 | description: 'Aborts an Actor run that is currently starting or running. ' 92 | + 'For runs with status FINISHED, FAILED, ABORTING, or TIMED-OUT, this call has no effect. ' 93 | + 'Returns the updated run details after aborting.', 94 | inputSchema: zodToJsonSchema(abortRunArgs), 95 | ajvValidate: ajv.compile(zodToJsonSchema(abortRunArgs)), 96 | call: async (toolArgs) => { 97 | const { args, apifyToken } = toolArgs; 98 | const parsed = abortRunArgs.parse(args); 99 | const client = new ApifyClient({ token: apifyToken }); 100 | const v = await client.run(parsed.runId).abort({ gracefully: parsed.gracefully }); 101 | return { content: [{ type: 'text', text: JSON.stringify(v) }] }; 102 | }, 103 | } as InternalTool, 104 | }; 105 | -------------------------------------------------------------------------------- /src/tools/run_collection.ts: -------------------------------------------------------------------------------- 1 | import { Ajv } from 'ajv'; 2 | import { z } from 'zod'; 3 | import zodToJsonSchema from 'zod-to-json-schema'; 4 | 5 | import { ApifyClient } from '../apify-client.js'; 6 | import { HelperTools } from '../const.js'; 7 | import type { InternalTool, ToolEntry } from '../types.js'; 8 | 9 | const ajv = new Ajv({ coerceTypes: 'array', strict: false }); 10 | 11 | const getUserRunsListArgs = z.object({ 12 | offset: z.number() 13 | .describe('Number of array elements that should be skipped at the start. The default value is 0.') 14 | .default(0), 15 | limit: z.number() 16 | .max(10) 17 | .describe('Maximum number of array elements to return. The default value (as well as the maximum) is 10.') 18 | .default(10), 19 | desc: z.boolean() 20 | .describe('If true or 1 then the runs are sorted by the startedAt field in descending order. Default: sorted in ascending order.') 21 | .default(false), 22 | status: z.enum(['READY', 'RUNNING', 'SUCCEEDED', 'FAILED', 'TIMING_OUT', 'TIMED_OUT', 'ABORTING', 'ABORTED']) 23 | .optional() 24 | .describe('Return only runs with the provided status.'), 25 | }); 26 | 27 | /** 28 | * https://docs.apify.com/api/v2/act-runs-get 29 | */ 30 | export const getUserRunsList: ToolEntry = { 31 | type: 'internal', 32 | tool: { 33 | name: HelperTools.ACTOR_RUN_LIST_GET, 34 | actorFullName: HelperTools.ACTOR_RUN_LIST_GET, 35 | description: `Gets a paginated list of Actor runs with run details, datasetId, and keyValueStoreId. 36 | Filter by status: READY (not allocated), RUNNING (executing), SUCCEEDED (finished), FAILED (failed), 37 | TIMING-OUT (timing out), TIMED-OUT (timed out), ABORTING (being aborted), ABORTED (aborted).`, 38 | inputSchema: zodToJsonSchema(getUserRunsListArgs), 39 | ajvValidate: ajv.compile(zodToJsonSchema(getUserRunsListArgs)), 40 | call: async (toolArgs) => { 41 | const { args, apifyToken } = toolArgs; 42 | const parsed = getUserRunsListArgs.parse(args); 43 | const client = new ApifyClient({ token: apifyToken }); 44 | const runs = await client.runs().list({ limit: parsed.limit, offset: parsed.offset, desc: parsed.desc, status: parsed.status }); 45 | return { content: [{ type: 'text', text: JSON.stringify(runs) }] }; 46 | }, 47 | } as InternalTool, 48 | }; 49 | -------------------------------------------------------------------------------- /src/tools/store_collection.ts: -------------------------------------------------------------------------------- 1 | import { Ajv } from 'ajv'; 2 | import type { ActorStoreList } from 'apify-client'; 3 | import { z } from 'zod'; 4 | import zodToJsonSchema from 'zod-to-json-schema'; 5 | 6 | import { ApifyClient } from '../apify-client.js'; 7 | import { HelperTools } from '../const.js'; 8 | import type { ActorStorePruned, HelperTool, PricingInfo, ToolEntry } from '../types.js'; 9 | 10 | function pruneActorStoreInfo(response: ActorStoreList): ActorStorePruned { 11 | const stats = response.stats || {}; 12 | const pricingInfo = (response.currentPricingInfo || {}) as PricingInfo; 13 | return { 14 | id: response.id, 15 | name: response.name?.toString() || '', 16 | username: response.username?.toString() || '', 17 | actorFullName: `${response.username}/${response.name}`, 18 | title: response.title?.toString() || '', 19 | description: response.description?.toString() || '', 20 | stats: { 21 | totalRuns: stats.totalRuns, 22 | totalUsers30Days: stats.totalUsers30Days, 23 | publicActorRunStats30Days: 'publicActorRunStats30Days' in stats 24 | ? stats.publicActorRunStats30Days : {}, 25 | }, 26 | currentPricingInfo: { 27 | pricingModel: pricingInfo.pricingModel?.toString() || '', 28 | pricePerUnitUsd: pricingInfo?.pricePerUnitUsd ?? 0, 29 | trialMinutes: pricingInfo?.trialMinutes ?? 0, 30 | }, 31 | url: response.url?.toString() || '', 32 | totalStars: 'totalStars' in response ? (response.totalStars as number) : null, 33 | }; 34 | } 35 | 36 | export async function searchActorsByKeywords( 37 | search: string, 38 | apifyToken: string, 39 | limit: number | undefined = undefined, 40 | offset: number | undefined = undefined, 41 | ): Promise { 42 | const client = new ApifyClient({ token: apifyToken }); 43 | const results = await client.store().list({ search, limit, offset }); 44 | return results.items.map((x) => pruneActorStoreInfo(x)); 45 | } 46 | 47 | const ajv = new Ajv({ coerceTypes: 'array', strict: false }); 48 | export const searchActorsArgsSchema = z.object({ 49 | limit: z.number() 50 | .int() 51 | .min(1) 52 | .max(100) 53 | .default(10) 54 | .describe('The maximum number of Actors to return. Default value is 10.'), 55 | offset: z.number() 56 | .int() 57 | .min(0) 58 | .default(0) 59 | .describe('The number of elements that should be skipped at the start. Default value is 0.'), 60 | search: z.string() 61 | .default('') 62 | .describe('String of key words to search Actors by. ' 63 | + 'Searches the title, name, description, username, and readme of an Actor.' 64 | + 'Only key word search is supported, no advanced search.' 65 | + 'Always prefer simple keywords over complex queries.'), 66 | category: z.string() 67 | .default('') 68 | .describe('Filters the results by the specified category.'), 69 | }); 70 | 71 | /** 72 | * https://docs.apify.com/api/v2/store-get 73 | */ 74 | export const searchActors: ToolEntry = { 75 | type: 'internal', 76 | tool: { 77 | name: HelperTools.STORE_SEARCH, 78 | actorFullName: HelperTools.STORE_SEARCH, 79 | description: `Discover available Actors or MCP-Servers in Apify Store using full text search using keywords.` 80 | + `Users try to discover Actors using free form query in this case search query must be converted to full text search. ` 81 | + `Returns a list of Actors with name, description, run statistics, pricing, starts, and URL. ` 82 | + `You perhaps need to use this tool several times to find the right Actor. ` 83 | + `You should prefer simple keywords over complex queries. ` 84 | + `Limit number of results returned but ensure that relevant results are returned. ` 85 | + `This is not a general search tool, it is designed to search for Actors in Apify Store. `, 86 | inputSchema: zodToJsonSchema(searchActorsArgsSchema), 87 | ajvValidate: ajv.compile(zodToJsonSchema(searchActorsArgsSchema)), 88 | call: async (toolArgs) => { 89 | const { args, apifyToken } = toolArgs; 90 | const parsed = searchActorsArgsSchema.parse(args); 91 | const actors = await searchActorsByKeywords( 92 | parsed.search, 93 | apifyToken, 94 | parsed.limit, 95 | parsed.offset, 96 | ); 97 | return { content: actors?.map((item) => ({ type: 'text', text: JSON.stringify(item) })) }; 98 | }, 99 | } as HelperTool, 100 | }; 101 | -------------------------------------------------------------------------------- /src/tools/utils.ts: -------------------------------------------------------------------------------- 1 | import { ACTOR_ENUM_MAX_LENGTH, ACTOR_MAX_DESCRIPTION_LENGTH } from '../const.js'; 2 | import type { IActorInputSchema, ISchemaProperties } from '../types.js'; 3 | 4 | export function actorNameToToolName(actorName: string): string { 5 | return actorName 6 | .replace(/\//g, '-slash-') 7 | .replace(/\./g, '-dot-') 8 | .slice(0, 64); 9 | } 10 | 11 | /** 12 | * Builds nested properties for object types in the schema. 13 | * 14 | * Specifically handles special cases like proxy configuration and request list sources 15 | * by adding predefined nested properties to these object types. 16 | * This is necessary for the agent to correctly infer how to structure object inputs 17 | * when passing arguments to the Actor. 18 | * 19 | * For proxy objects (type='object', editor='proxy'), adds 'useApifyProxy' property. 20 | * For request list sources (type='array', editor='requestListSources'), adds URL structure to items. 21 | * 22 | * @param {Record} properties - The input schema properties 23 | * @returns {Record} Modified properties with nested properties 24 | */ 25 | export function buildNestedProperties(properties: Record): Record { 26 | const clonedProperties = { ...properties }; 27 | 28 | for (const [propertyName, property] of Object.entries(clonedProperties)) { 29 | if (property.type === 'object' && property.editor === 'proxy') { 30 | clonedProperties[propertyName] = { 31 | ...property, 32 | properties: { 33 | ...property.properties, 34 | useApifyProxy: { 35 | title: 'Use Apify Proxy', 36 | type: 'boolean', 37 | description: 'Whether to use Apify Proxy - ALWAYS SET TO TRUE.', 38 | default: true, 39 | examples: [true], 40 | }, 41 | }, 42 | required: ['useApifyProxy'], 43 | }; 44 | } else if (property.type === 'array' && property.editor === 'requestListSources') { 45 | clonedProperties[propertyName] = { 46 | ...property, 47 | items: { 48 | ...property.items, 49 | type: 'object', 50 | title: 'Request list source', 51 | description: 'Request list source', 52 | properties: { 53 | url: { 54 | title: 'URL', 55 | type: 'string', 56 | description: 'URL of the request list source', 57 | }, 58 | }, 59 | }, 60 | }; 61 | } 62 | } 63 | 64 | return clonedProperties; 65 | } 66 | 67 | /** 68 | * Filters schema properties to include only the necessary fields. 69 | * 70 | * This is done to reduce the size of the input schema and to make it more readable. 71 | * 72 | * @param properties 73 | */ 74 | export function filterSchemaProperties(properties: { [key: string]: ISchemaProperties }): { 75 | [key: string]: ISchemaProperties 76 | } { 77 | const filteredProperties: { [key: string]: ISchemaProperties } = {}; 78 | for (const [key, property] of Object.entries(properties)) { 79 | filteredProperties[key] = { 80 | title: property.title, 81 | description: property.description, 82 | enum: property.enum, 83 | type: property.type, 84 | default: property.default, 85 | prefill: property.prefill, 86 | properties: property.properties, 87 | items: property.items, 88 | required: property.required, 89 | }; 90 | if (property.type === 'array' && !property.items?.type) { 91 | const itemsType = inferArrayItemType(property); 92 | if (itemsType) { 93 | filteredProperties[key].items = { 94 | ...filteredProperties[key].items, 95 | title: filteredProperties[key].title ?? 'Item', 96 | description: filteredProperties[key].description ?? 'Item', 97 | type: itemsType, 98 | }; 99 | } 100 | } 101 | } 102 | return filteredProperties; 103 | } 104 | 105 | /** 106 | * Marks input properties as required by adding a "REQUIRED" prefix to their descriptions. 107 | * Takes an IActorInput object and returns a modified Record of SchemaProperties. 108 | * 109 | * This is done for maximum compatibility in case where library or agent framework does not consider 110 | * required fields and does not handle the JSON schema properly: we are prepending this to the description 111 | * as a preventive measure. 112 | * @param {IActorInputSchema} input - Actor input object containing properties and required fields 113 | * @returns {Record} - Modified properties with required fields marked 114 | */ 115 | export function markInputPropertiesAsRequired(input: IActorInputSchema): Record { 116 | const { required = [], properties } = input; 117 | 118 | for (const property of Object.keys(properties)) { 119 | if (required.includes(property)) { 120 | properties[property] = { 121 | ...properties[property], 122 | description: `**REQUIRED** ${properties[property].description}`, 123 | }; 124 | } 125 | } 126 | 127 | return properties; 128 | } 129 | 130 | /** 131 | * Helps determine the type of items in an array schema property. 132 | * Priority order: explicit type in items > prefill type > default value type > editor type. 133 | * 134 | * Based on JSON schema, the array needs a type, and most of the time Actor input schema does not have this, so we need to infer that. 135 | * 136 | */ 137 | export function inferArrayItemType(property: ISchemaProperties): string | null { 138 | return property.items?.type 139 | || (Array.isArray(property.prefill) && property.prefill.length > 0 && typeof property.prefill[0]) 140 | || (Array.isArray(property.default) && property.default.length > 0 && typeof property.default[0]) 141 | || (property.editor && getEditorItemType(property.editor)) 142 | || null; 143 | 144 | function getEditorItemType(editor: string): string | null { 145 | const editorTypeMap: Record = { 146 | requestListSources: 'object', 147 | stringList: 'string', 148 | json: 'object', 149 | globs: 'object', 150 | }; 151 | return editorTypeMap[editor] || null; 152 | } 153 | } 154 | 155 | /** 156 | * Add enum values as string to property descriptions. 157 | * 158 | * This is done as a preventive measure to prevent cases where library or agent framework 159 | * does not handle enums or examples based on JSON schema definition. 160 | * 161 | * https://json-schema.org/understanding-json-schema/reference/enum 162 | * https://json-schema.org/understanding-json-schema/reference/annotations 163 | * 164 | * @param properties 165 | */ 166 | export function addEnumsToDescriptionsWithExamples(properties: Record): Record { 167 | for (const property of Object.values(properties)) { 168 | if (property.enum && property.enum.length > 0) { 169 | property.description = `${property.description}\nPossible values: ${property.enum.slice(0, 20).join(',')}`; 170 | } 171 | const value = property.prefill ?? property.default; 172 | if (value && !(Array.isArray(value) && value.length === 0)) { 173 | property.examples = Array.isArray(value) ? value : [value]; 174 | property.description = `${property.description}\nExample values: ${JSON.stringify(value)}`; 175 | } 176 | } 177 | return properties; 178 | } 179 | 180 | /** 181 | * Helper function to shorten the enum list if it is too long. 182 | * 183 | * @param {string[]} enumList - The list of enum values to be shortened. 184 | * @returns {string[] | undefined} - The shortened enum list or undefined if the list is too long. 185 | */ 186 | export function shortenEnum(enumList: string[]): string[] | undefined { 187 | let charCount = 0; 188 | const resultEnumList = enumList.filter((enumValue) => { 189 | charCount += enumValue.length; 190 | return charCount <= ACTOR_ENUM_MAX_LENGTH; 191 | }); 192 | 193 | return resultEnumList.length > 0 ? resultEnumList : undefined; 194 | } 195 | 196 | /** 197 | * Shortens the description, enum, and items.enum properties of the schema properties. 198 | * @param properties 199 | */ 200 | export function shortenProperties(properties: { [key: string]: ISchemaProperties }): { 201 | [key: string]: ISchemaProperties 202 | } { 203 | for (const property of Object.values(properties)) { 204 | if (property.description.length > ACTOR_MAX_DESCRIPTION_LENGTH) { 205 | property.description = `${property.description.slice(0, ACTOR_MAX_DESCRIPTION_LENGTH)}...`; 206 | } 207 | 208 | if (property.enum && property.enum?.length > 0) { 209 | property.enum = shortenEnum(property.enum); 210 | } 211 | 212 | if (property.items?.enum && property.items.enum.length > 0) { 213 | property.items.enum = shortenEnum(property.items.enum); 214 | } 215 | } 216 | 217 | return properties; 218 | } 219 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "../dist", 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; 2 | import type { ValidateFunction } from 'ajv'; 3 | import type { ActorDefaultRunOptions, ActorDefinition } from 'apify-client'; 4 | 5 | import type { ActorsMcpServer } from './mcp/server.js'; 6 | 7 | export interface ISchemaProperties { 8 | type: string; 9 | 10 | title: string; 11 | description: string; 12 | 13 | enum?: string[]; // Array of string options for the enum 14 | enumTitles?: string[]; // Array of string titles for the enum 15 | default?: unknown; 16 | prefill?: unknown; 17 | 18 | items?: ISchemaProperties; 19 | editor?: string; 20 | examples?: unknown[]; 21 | 22 | properties?: Record; 23 | required?: string[]; 24 | } 25 | 26 | export interface IActorInputSchema { 27 | title?: string; 28 | description?: string; 29 | 30 | type: string; 31 | 32 | properties: Record; 33 | 34 | required?: string[]; 35 | schemaVersion?: number; 36 | } 37 | 38 | export type ActorDefinitionWithDesc = Omit & { 39 | id: string; 40 | actorFullName: string; 41 | description: string; 42 | defaultRunOptions: ActorDefaultRunOptions; 43 | input?: IActorInputSchema; 44 | } 45 | 46 | export type ActorDefinitionPruned = Pick 48 | 49 | /** 50 | * Base interface for all tools in the MCP server. 51 | * Contains common properties shared by all tool types. 52 | */ 53 | export interface ToolBase { 54 | /** Unique name/identifier for the tool */ 55 | name: string; 56 | /** Description of what the tool does */ 57 | description: string; 58 | /** JSON schema defining the tool's input parameters */ 59 | inputSchema: object; 60 | /** AJV validation function for the input schema */ 61 | ajvValidate: ValidateFunction; 62 | } 63 | 64 | /** 65 | * Interface for Actor-based tools - tools that wrap Apify Actors. 66 | * Extends ToolBase with Actor-specific properties. 67 | */ 68 | export interface ActorTool extends ToolBase { 69 | /** Full name of the Apify Actor (username/name) */ 70 | actorFullName: string; 71 | /** Optional memory limit in MB for the Actor execution */ 72 | memoryMbytes?: number; 73 | } 74 | 75 | /** 76 | * Arguments passed to internal tool calls. 77 | * Contains both the tool arguments and server references. 78 | */ 79 | export type InternalToolArgs = { 80 | /** Arguments passed to the tool */ 81 | args: Record; 82 | /** Reference to the Apify MCP server instance */ 83 | apifyMcpServer: ActorsMcpServer; 84 | /** Reference to the MCP server instance */ 85 | mcpServer: Server; 86 | /** Apify API token */ 87 | apifyToken: string; 88 | } 89 | 90 | /** 91 | * Interface for internal tools - tools implemented directly in the MCP server. 92 | * Extends ToolBase with a call function implementation. 93 | */ 94 | export interface HelperTool extends ToolBase { 95 | /** 96 | * Executes the tool with the given arguments 97 | * @param toolArgs - Arguments and server references 98 | * @returns Promise resolving to the tool's output 99 | */ 100 | call: (toolArgs: InternalToolArgs) => Promise; 101 | } 102 | 103 | /** 104 | * Actorized MCP server tool where this MCP server acts as a proxy. 105 | * Extends ToolBase with a tool-associated MCP server. 106 | */ 107 | export interface ActorMcpTool extends ToolBase { 108 | // Origin MCP server tool name is needed for the tool call 109 | originToolName: string; 110 | // ID of the Actorized MCP server - for example, apify/actors-mcp-server 111 | actorId: string; 112 | /** 113 | * ID of the Actorized MCP server the tool is associated with. 114 | * serverId is generated unique ID based on the serverUrl. 115 | */ 116 | serverId: string; 117 | // Connection URL of the Actorized MCP server 118 | serverUrl: string; 119 | } 120 | 121 | /** 122 | * Type discriminator for tools - indicates whether a tool is internal or Actor-based. 123 | */ 124 | export type ToolType = 'internal' | 'actor' | 'actor-mcp'; 125 | 126 | /** 127 | * Wrapper interface that combines a tool with its type discriminator. 128 | * Used to store and manage tools of different types uniformly. 129 | */ 130 | export interface ToolEntry { 131 | /** Type of the tool (internal or actor) */ 132 | type: ToolType; 133 | /** The tool instance */ 134 | tool: ActorTool | HelperTool | ActorMcpTool; 135 | } 136 | 137 | // ActorStoreList for actor-search tool 138 | export interface ActorStats { 139 | totalRuns: number; 140 | totalUsers30Days: number; 141 | publicActorRunStats30Days: unknown; 142 | } 143 | 144 | export interface PricingInfo { 145 | pricingModel?: string; 146 | pricePerUnitUsd?: number; 147 | trialMinutes?: number 148 | } 149 | 150 | export interface ActorStorePruned { 151 | id: string; 152 | name: string; 153 | username: string; 154 | actorFullName?: string; 155 | title?: string; 156 | description?: string; 157 | stats: ActorStats; 158 | currentPricingInfo: PricingInfo; 159 | url: string; 160 | totalStars?: number | null; 161 | } 162 | 163 | /** 164 | * Interface for internal tools - tools implemented directly in the MCP server. 165 | * Extends ToolBase with a call function implementation. 166 | */ 167 | export interface InternalTool extends ToolBase { 168 | /** 169 | * Executes the tool with the given arguments 170 | * @param toolArgs - Arguments and server references 171 | * @returns Promise resolving to the tool's output 172 | */ 173 | call: (toolArgs: InternalToolArgs) => Promise; 174 | } 175 | 176 | export type Input = { 177 | actors: string[] | string; 178 | /** 179 | * @deprecated Use `enableAddingActors` instead. 180 | */ 181 | enableActorAutoLoading?: boolean | string; 182 | enableAddingActors?: boolean | string; 183 | maxActorMemoryBytes?: number; 184 | debugActor?: string; 185 | debugActorInput?: unknown; 186 | }; 187 | 188 | export interface ToolCacheEntry { 189 | expiresAt: number; 190 | tool: ToolEntry; 191 | } 192 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Tests 2 | 3 | This directory contains **unit** and **integration** tests for the `actors-mcp-server` project. 4 | 5 | # Unit Tests 6 | 7 | Unit tests are located in the `tests/unit` directory. 8 | 9 | To run the unit tests, you can use the following command: 10 | ```bash 11 | npm run test:unit 12 | ``` 13 | 14 | # Integration Tests 15 | 16 | Integration tests are located in the `tests/integration` directory. 17 | In order to run the integration tests, you need to have the `APIFY_TOKEN` environment variable set. 18 | Also following Actors need to exist on the target execution Apify platform: 19 | ``` 20 | ALL DEFAULT ONES DEFINED IN consts.ts AND ALSO EXPLICITLY: 21 | apify/rag-web-browser 22 | apify/instagram-scraper 23 | apify/python-example 24 | ``` 25 | 26 | To run the integration tests, you can use the following command: 27 | ```bash 28 | APIFY_TOKEN=your_token npm run test:integration 29 | ``` 30 | -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 2 | import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; 3 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 4 | import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; 5 | import { expect } from 'vitest'; 6 | 7 | import { HelperTools } from '../src/const.js'; 8 | 9 | export interface McpClientOptions { 10 | actors?: string[]; 11 | enableAddingActors?: boolean; 12 | } 13 | 14 | export async function createMcpSseClient( 15 | serverUrl: string, 16 | options?: McpClientOptions, 17 | ): Promise { 18 | if (!process.env.APIFY_TOKEN) { 19 | throw new Error('APIFY_TOKEN environment variable is not set.'); 20 | } 21 | const url = new URL(serverUrl); 22 | const { actors, enableAddingActors } = options || {}; 23 | if (actors) { 24 | url.searchParams.append('actors', actors.join(',')); 25 | } 26 | if (enableAddingActors) { 27 | url.searchParams.append('enableAddingActors', 'true'); 28 | } 29 | 30 | const transport = new SSEClientTransport( 31 | url, 32 | { 33 | requestInit: { 34 | headers: { 35 | authorization: `Bearer ${process.env.APIFY_TOKEN}`, 36 | }, 37 | }, 38 | }, 39 | ); 40 | 41 | const client = new Client({ 42 | name: 'sse-client', 43 | version: '1.0.0', 44 | }); 45 | await client.connect(transport); 46 | 47 | return client; 48 | } 49 | 50 | export async function createMcpStreamableClient( 51 | serverUrl: string, 52 | options?: McpClientOptions, 53 | ): Promise { 54 | if (!process.env.APIFY_TOKEN) { 55 | throw new Error('APIFY_TOKEN environment variable is not set.'); 56 | } 57 | const url = new URL(serverUrl); 58 | const { actors, enableAddingActors } = options || {}; 59 | if (actors) { 60 | url.searchParams.append('actors', actors.join(',')); 61 | } 62 | if (enableAddingActors) { 63 | url.searchParams.append('enableAddingActors', 'true'); 64 | } 65 | 66 | const transport = new StreamableHTTPClientTransport( 67 | url, 68 | { 69 | requestInit: { 70 | headers: { 71 | authorization: `Bearer ${process.env.APIFY_TOKEN}`, 72 | }, 73 | }, 74 | }, 75 | ); 76 | 77 | const client = new Client({ 78 | name: 'streamable-http-client', 79 | version: '1.0.0', 80 | }); 81 | await client.connect(transport); 82 | 83 | return client; 84 | } 85 | 86 | export async function createMcpStdioClient( 87 | options?: McpClientOptions, 88 | ): Promise { 89 | if (!process.env.APIFY_TOKEN) { 90 | throw new Error('APIFY_TOKEN environment variable is not set.'); 91 | } 92 | const { actors, enableAddingActors } = options || {}; 93 | const args = ['dist/stdio.js']; 94 | if (actors) { 95 | args.push('--actors', actors.join(',')); 96 | } 97 | if (enableAddingActors) { 98 | args.push('--enable-adding-actors'); 99 | } 100 | const transport = new StdioClientTransport({ 101 | command: 'node', 102 | args, 103 | env: { 104 | APIFY_TOKEN: process.env.APIFY_TOKEN as string, 105 | }, 106 | }); 107 | const client = new Client({ 108 | name: 'stdio-client', 109 | version: '1.0.0', 110 | }); 111 | await client.connect(transport); 112 | 113 | return client; 114 | } 115 | 116 | /** 117 | * Adds an Actor as a tool using the ADD_ACTOR helper tool. 118 | * @param client - MCP client instance 119 | * @param actorName - Name of the Actor to add 120 | */ 121 | export async function addActor(client: Client, actorName: string): Promise { 122 | await client.callTool({ 123 | name: HelperTools.ACTOR_ADD, 124 | arguments: { 125 | actorName, 126 | }, 127 | }); 128 | } 129 | 130 | /** 131 | * Asserts that two arrays contain the same elements, regardless of order. 132 | * @param array - The array to test 133 | * @param values - The expected values 134 | */ 135 | export function expectArrayWeakEquals(array: unknown[], values: unknown[]): void { 136 | expect(array.length).toBe(values.length); 137 | for (const value of values) { 138 | expect(array).toContainEqual(value); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /tests/integration/actor.server-sse.test.ts: -------------------------------------------------------------------------------- 1 | import type { Server as HttpServer } from 'node:http'; 2 | 3 | import type { Express } from 'express'; 4 | 5 | import log from '@apify/log'; 6 | 7 | import { createExpressApp } from '../../src/actor/server.js'; 8 | import { ActorsMcpServer } from '../../src/mcp/server.js'; 9 | import { createMcpSseClient } from '../helpers.js'; 10 | import { createIntegrationTestsSuite } from './suite.js'; 11 | 12 | let app: Express; 13 | let mcpServer: ActorsMcpServer; 14 | let httpServer: HttpServer; 15 | const httpServerPort = 50000; 16 | const httpServerHost = `http://localhost:${httpServerPort}`; 17 | const mcpUrl = `${httpServerHost}/sse`; 18 | 19 | createIntegrationTestsSuite({ 20 | suiteName: 'Actors MCP Server SSE', 21 | getActorsMcpServer: () => mcpServer, 22 | createClientFn: async (options) => await createMcpSseClient(mcpUrl, options), 23 | beforeAllFn: async () => { 24 | mcpServer = new ActorsMcpServer({ enableAddingActors: false, enableDefaultActors: false }); 25 | log.setLevel(log.LEVELS.OFF); 26 | 27 | // Create an express app using the proper server setup 28 | app = createExpressApp(httpServerHost, mcpServer); 29 | 30 | // Start a test server 31 | await new Promise((resolve) => { 32 | httpServer = app.listen(httpServerPort, () => resolve()); 33 | }); 34 | }, 35 | beforeEachFn: async () => { 36 | mcpServer.disableDynamicActorTools(); 37 | await mcpServer.reset(); 38 | }, 39 | afterAllFn: async () => { 40 | await mcpServer.close(); 41 | await new Promise((resolve) => { 42 | httpServer.close(() => resolve()); 43 | }); 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /tests/integration/actor.server-streamable.test.ts: -------------------------------------------------------------------------------- 1 | import type { Server as HttpServer } from 'node:http'; 2 | 3 | import type { Express } from 'express'; 4 | 5 | import log from '@apify/log'; 6 | 7 | import { createExpressApp } from '../../src/actor/server.js'; 8 | import { ActorsMcpServer } from '../../src/mcp/server.js'; 9 | import { createMcpStreamableClient } from '../helpers.js'; 10 | import { createIntegrationTestsSuite } from './suite.js'; 11 | 12 | let app: Express; 13 | let mcpServer: ActorsMcpServer; 14 | let httpServer: HttpServer; 15 | const httpServerPort = 50001; 16 | const httpServerHost = `http://localhost:${httpServerPort}`; 17 | const mcpUrl = `${httpServerHost}/mcp`; 18 | 19 | createIntegrationTestsSuite({ 20 | suiteName: 'Actors MCP Server Streamable HTTP', 21 | getActorsMcpServer: () => mcpServer, 22 | createClientFn: async (options) => await createMcpStreamableClient(mcpUrl, options), 23 | beforeAllFn: async () => { 24 | log.setLevel(log.LEVELS.OFF); 25 | // Create an express app using the proper server setup 26 | mcpServer = new ActorsMcpServer({ enableAddingActors: false, enableDefaultActors: false }); 27 | app = createExpressApp(httpServerHost, mcpServer); 28 | 29 | // Start a test server 30 | await new Promise((resolve) => { 31 | httpServer = app.listen(httpServerPort, () => resolve()); 32 | }); 33 | }, 34 | beforeEachFn: async () => { 35 | mcpServer.disableDynamicActorTools(); 36 | await mcpServer.reset(); 37 | }, 38 | afterAllFn: async () => { 39 | await new Promise((resolve) => { 40 | httpServer.close(() => resolve()); 41 | }); 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /tests/integration/stdio.test.ts: -------------------------------------------------------------------------------- 1 | import { createMcpStdioClient } from '../helpers.js'; 2 | import { createIntegrationTestsSuite } from './suite.js'; 3 | 4 | createIntegrationTestsSuite({ 5 | suiteName: 'MCP stdio', 6 | createClientFn: createMcpStdioClient, 7 | }); 8 | -------------------------------------------------------------------------------- /tests/integration/suite.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; 2 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; 3 | 4 | import { defaults, HelperTools } from '../../src/const.js'; 5 | import type { ActorsMcpServer } from '../../src/index.js'; 6 | import { addRemoveTools, defaultTools } from '../../src/tools/index.js'; 7 | import { actorNameToToolName } from '../../src/tools/utils.js'; 8 | import { addActor, expectArrayWeakEquals, type McpClientOptions } from '../helpers.js'; 9 | 10 | interface IntegrationTestsSuiteOptions { 11 | suiteName: string; 12 | getActorsMcpServer?: () => ActorsMcpServer; 13 | createClientFn: (options?: McpClientOptions) => Promise; 14 | beforeAllFn?: () => Promise; 15 | afterAllFn?: () => Promise; 16 | beforeEachFn?: () => Promise; 17 | afterEachFn?: () => Promise; 18 | } 19 | 20 | const ACTOR_PYTHON_EXAMPLE = 'apify/python-example'; 21 | const DEFAULT_TOOL_NAMES = defaultTools.map((tool) => tool.tool.name); 22 | const DEFAULT_ACTOR_NAMES = defaults.actors.map((tool) => actorNameToToolName(tool)); 23 | 24 | function getToolNames(tools: { tools: { name: string }[] }) { 25 | return tools.tools.map((tool) => tool.name); 26 | } 27 | 28 | function expectToolNamesToContain(names: string[], toolNames: string[] = []) { 29 | toolNames.forEach((name) => expect(names).toContain(name)); 30 | } 31 | 32 | async function callPythonExampleActor(client: Client, selectedToolName: string) { 33 | const result = await client.callTool({ 34 | name: selectedToolName, 35 | arguments: { 36 | first_number: 1, 37 | second_number: 2, 38 | }, 39 | }); 40 | 41 | type ContentItem = { text: string; type: string }; 42 | const content = result.content as ContentItem[]; 43 | // The result is { content: [ ... ] }, and the last content is the sum 44 | expect(content[content.length - 1]).toEqual({ 45 | text: JSON.stringify({ 46 | first_number: 1, 47 | second_number: 2, 48 | sum: 3, 49 | }), 50 | type: 'text', 51 | }); 52 | } 53 | 54 | export function createIntegrationTestsSuite( 55 | options: IntegrationTestsSuiteOptions, 56 | ) { 57 | const { 58 | suiteName, 59 | getActorsMcpServer, 60 | createClientFn, 61 | beforeAllFn, 62 | afterAllFn, 63 | beforeEachFn, 64 | afterEachFn, 65 | } = options; 66 | 67 | // Hooks 68 | if (beforeAllFn) { 69 | beforeAll(beforeAllFn); 70 | } 71 | if (afterAllFn) { 72 | afterAll(afterAllFn); 73 | } 74 | if (beforeEachFn) { 75 | beforeEach(beforeEachFn); 76 | } 77 | if (afterEachFn) { 78 | afterEach(afterEachFn); 79 | } 80 | 81 | describe(suiteName, { 82 | concurrent: false, // Make all tests sequential to prevent state interference 83 | }, () => { 84 | it('should list all default tools and default Actors', async () => { 85 | const client = await createClientFn(); 86 | const tools = await client.listTools(); 87 | expect(tools.tools.length).toEqual(defaultTools.length + defaults.actors.length); 88 | 89 | const names = getToolNames(tools); 90 | expectToolNamesToContain(names, DEFAULT_TOOL_NAMES); 91 | expectToolNamesToContain(names, DEFAULT_ACTOR_NAMES); 92 | await client.close(); 93 | }); 94 | 95 | it('should list all default tools, tools for adding/removing Actors, and default Actors', async () => { 96 | const client = await createClientFn({ enableAddingActors: true }); 97 | const names = getToolNames(await client.listTools()); 98 | expect(names.length).toEqual(defaultTools.length + defaults.actors.length + addRemoveTools.length); 99 | 100 | expectToolNamesToContain(names, DEFAULT_TOOL_NAMES); 101 | expectToolNamesToContain(names, DEFAULT_ACTOR_NAMES); 102 | expectToolNamesToContain(names, addRemoveTools.map((tool) => tool.tool.name)); 103 | await client.close(); 104 | }); 105 | 106 | it('should list all default tools and two loaded Actors', async () => { 107 | const actors = ['apify/website-content-crawler', 'apify/instagram-scraper']; 108 | const client = await createClientFn({ actors, enableAddingActors: false }); 109 | const names = getToolNames(await client.listTools()); 110 | expect(names.length).toEqual(defaultTools.length + actors.length); 111 | expectToolNamesToContain(names, DEFAULT_TOOL_NAMES); 112 | expectToolNamesToContain(names, actors.map((actor) => actorNameToToolName(actor))); 113 | 114 | await client.close(); 115 | }); 116 | 117 | it('should add Actor dynamically and call it', async () => { 118 | const selectedToolName = actorNameToToolName(ACTOR_PYTHON_EXAMPLE); 119 | const client = await createClientFn({ enableAddingActors: true }); 120 | const names = getToolNames(await client.listTools()); 121 | const numberOfTools = defaultTools.length + addRemoveTools.length + defaults.actors.length; 122 | expect(names.length).toEqual(numberOfTools); 123 | // Check that the Actor is not in the tools list 124 | expect(names).not.toContain(selectedToolName); 125 | // Add Actor dynamically 126 | await client.callTool({ name: HelperTools.ACTOR_ADD, arguments: { actorName: ACTOR_PYTHON_EXAMPLE } }); 127 | 128 | // Check if tools was added 129 | const namesAfterAdd = getToolNames(await client.listTools()); 130 | expect(namesAfterAdd.length).toEqual(numberOfTools + 1); 131 | expect(namesAfterAdd).toContain(selectedToolName); 132 | await callPythonExampleActor(client, selectedToolName); 133 | 134 | await client.close(); 135 | }); 136 | 137 | it('should remove Actor from tools list', async () => { 138 | const actor = ACTOR_PYTHON_EXAMPLE; 139 | const selectedToolName = actorNameToToolName(actor); 140 | const client = await createClientFn({ 141 | actors: [actor], 142 | enableAddingActors: true, 143 | }); 144 | 145 | // Verify actor is in the tools list 146 | const namesBefore = getToolNames(await client.listTools()); 147 | expect(namesBefore).toContain(selectedToolName); 148 | 149 | // Remove the actor 150 | await client.callTool({ name: HelperTools.ACTOR_REMOVE, arguments: { toolName: selectedToolName } }); 151 | 152 | // Verify actor is removed 153 | const namesAfter = getToolNames(await client.listTools()); 154 | expect(namesAfter).not.toContain(selectedToolName); 155 | 156 | await client.close(); 157 | }); 158 | 159 | it('should find Actors in store search', async () => { 160 | const query = 'python-example'; 161 | const client = await createClientFn({ 162 | enableAddingActors: false, 163 | }); 164 | 165 | const result = await client.callTool({ 166 | name: HelperTools.STORE_SEARCH, 167 | arguments: { 168 | search: query, 169 | limit: 5, 170 | }, 171 | }); 172 | const content = result.content as {text: string}[]; 173 | expect(content.some((item) => item.text.includes(ACTOR_PYTHON_EXAMPLE))).toBe(true); 174 | 175 | await client.close(); 176 | }); 177 | 178 | // Execute only when we can get the MCP server instance - currently skips only stdio 179 | // is skipped because we are running a compiled version through node and there is no way (easy) 180 | // to get the MCP server instance 181 | it.runIf(getActorsMcpServer)('should load and restore tools from a tool list', async () => { 182 | const client = await createClientFn({ enableAddingActors: true }); 183 | const actorsMcpServer = getActorsMcpServer!(); 184 | 185 | // Add a new Actor 186 | await addActor(client, ACTOR_PYTHON_EXAMPLE); 187 | 188 | // Store the tool name list 189 | const names = actorsMcpServer.listAllToolNames(); 190 | const expectedToolNames = [ 191 | ...DEFAULT_TOOL_NAMES, 192 | ...defaults.actors, 193 | ...addRemoveTools.map((tool) => tool.tool.name), 194 | ...[ACTOR_PYTHON_EXAMPLE], 195 | ]; 196 | expectArrayWeakEquals(expectedToolNames, names); 197 | 198 | // Remove all tools 199 | actorsMcpServer.tools.clear(); 200 | expect(actorsMcpServer.listAllToolNames()).toEqual([]); 201 | 202 | // Load the tool state from the tool name list 203 | await actorsMcpServer.loadToolsByName(names, process.env.APIFY_TOKEN as string); 204 | 205 | // Check if the tool name list is restored 206 | expectArrayWeakEquals(actorsMcpServer.listAllToolNames(), expectedToolNames); 207 | 208 | await client.close(); 209 | }); 210 | 211 | it.runIf(getActorsMcpServer)('should reset and restore tool state with default tools', async () => { 212 | const firstClient = await createClientFn({ enableAddingActors: true }); 213 | const actorsMCPServer = getActorsMcpServer!(); 214 | const numberOfTools = defaultTools.length + addRemoveTools.length + defaults.actors.length; 215 | const toolList = actorsMCPServer.listAllToolNames(); 216 | expect(toolList.length).toEqual(numberOfTools); 217 | // Add a new Actor 218 | await addActor(firstClient, ACTOR_PYTHON_EXAMPLE); 219 | 220 | // Store the tool name list 221 | const toolListWithActor = actorsMCPServer.listAllToolNames(); 222 | expect(toolListWithActor.length).toEqual(numberOfTools + 1); // + 1 for the added Actor 223 | await firstClient.close(); 224 | 225 | // Remove all tools 226 | await actorsMCPServer.reset(); 227 | // We connect second client so that the default tools are loaded 228 | // if no specific list of Actors is provided 229 | const secondClient = await createClientFn({ enableAddingActors: true }); 230 | const toolListAfterReset = actorsMCPServer.listAllToolNames(); 231 | expect(toolListAfterReset.length).toEqual(numberOfTools); 232 | await secondClient.close(); 233 | }); 234 | 235 | it.runIf(getActorsMcpServer)('should notify tools changed handler on tool modifications', async () => { 236 | const client = await createClientFn({ enableAddingActors: true }); 237 | let latestTools: string[] = []; 238 | const numberOfTools = defaultTools.length + addRemoveTools.length + defaults.actors.length; 239 | 240 | let toolNotificationCount = 0; 241 | const onToolsChanged = (tools: string[]) => { 242 | latestTools = tools; 243 | toolNotificationCount++; 244 | }; 245 | 246 | const actorsMCPServer = getActorsMcpServer!(); 247 | actorsMCPServer.registerToolsChangedHandler(onToolsChanged); 248 | 249 | // Add a new Actor 250 | const actor = ACTOR_PYTHON_EXAMPLE; 251 | await client.callTool({ 252 | name: HelperTools.ACTOR_ADD, 253 | arguments: { 254 | actorName: actor, 255 | }, 256 | }); 257 | 258 | // Check if the notification was received with the correct tools 259 | expect(toolNotificationCount).toBe(1); 260 | expect(latestTools.length).toBe(numberOfTools + 1); 261 | expect(latestTools).toContain(actor); 262 | for (const tool of [...defaultTools, ...addRemoveTools]) { 263 | expect(latestTools).toContain(tool.tool.name); 264 | } 265 | for (const tool of defaults.actors) { 266 | expect(latestTools).toContain(tool); 267 | } 268 | 269 | // Remove the Actor 270 | await client.callTool({ 271 | name: HelperTools.ACTOR_REMOVE, 272 | arguments: { 273 | toolName: actorNameToToolName(actor), 274 | }, 275 | }); 276 | 277 | // Check if the notification was received with the correct tools 278 | expect(toolNotificationCount).toBe(2); 279 | expect(latestTools.length).toBe(numberOfTools); 280 | expect(latestTools).not.toContain(actor); 281 | for (const tool of [...defaultTools, ...addRemoveTools]) { 282 | expect(latestTools).toContain(tool.tool.name); 283 | } 284 | for (const tool of defaults.actors) { 285 | expect(latestTools).toContain(tool); 286 | } 287 | 288 | await client.close(); 289 | }); 290 | 291 | it.runIf(getActorsMcpServer)('should stop notifying after unregistering tools changed handler', async () => { 292 | const client = await createClientFn({ enableAddingActors: true }); 293 | let latestTools: string[] = []; 294 | let notificationCount = 0; 295 | const numberOfTools = defaultTools.length + addRemoveTools.length + defaults.actors.length; 296 | const onToolsChanged = (tools: string[]) => { 297 | latestTools = tools; 298 | notificationCount++; 299 | }; 300 | 301 | const actorsMCPServer = getActorsMcpServer!(); 302 | actorsMCPServer.registerToolsChangedHandler(onToolsChanged); 303 | 304 | // Add a new Actor 305 | const actor = ACTOR_PYTHON_EXAMPLE; 306 | await client.callTool({ 307 | name: HelperTools.ACTOR_ADD, 308 | arguments: { 309 | actorName: actor, 310 | }, 311 | }); 312 | 313 | // Check if the notification was received 314 | expect(notificationCount).toBe(1); 315 | expect(latestTools.length).toBe(numberOfTools + 1); 316 | expect(latestTools).toContain(actor); 317 | 318 | actorsMCPServer.unregisterToolsChangedHandler(); 319 | 320 | // Remove the Actor 321 | await client.callTool({ 322 | name: HelperTools.ACTOR_REMOVE, 323 | arguments: { 324 | toolName: actorNameToToolName(actor), 325 | }, 326 | }); 327 | 328 | // Check if the notification was NOT received 329 | expect(notificationCount).toBe(1); 330 | await client.close(); 331 | }); 332 | }); 333 | } 334 | -------------------------------------------------------------------------------- /tests/unit/input.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { processInput } from '../../src/input.js'; 4 | import type { Input } from '../../src/types.js'; 5 | 6 | describe('processInput', () => { 7 | it('should handle string actors input and convert to array', async () => { 8 | const input: Partial = { 9 | actors: 'actor1, actor2,actor3', 10 | }; 11 | const processed = processInput(input); 12 | expect(processed.actors).toEqual(['actor1', 'actor2', 'actor3']); 13 | }); 14 | 15 | it('should keep array actors input unchanged', async () => { 16 | const input: Partial = { 17 | actors: ['actor1', 'actor2', 'actor3'], 18 | }; 19 | const processed = processInput(input); 20 | expect(processed.actors).toEqual(['actor1', 'actor2', 'actor3']); 21 | }); 22 | 23 | it('should handle enableActorAutoLoading to set enableAddingActors', async () => { 24 | const input: Partial = { 25 | actors: ['actor1'], 26 | enableActorAutoLoading: true, 27 | }; 28 | const processed = processInput(input); 29 | expect(processed.enableAddingActors).toBe(true); 30 | }); 31 | 32 | it('should not override existing enableAddingActors with enableActorAutoLoading', async () => { 33 | const input: Partial = { 34 | actors: ['actor1'], 35 | enableActorAutoLoading: true, 36 | enableAddingActors: false, 37 | }; 38 | const processed = processInput(input); 39 | expect(processed.enableAddingActors).toBe(false); 40 | }); 41 | 42 | it('should default enableAddingActors to false when not provided', async () => { 43 | const input: Partial = { 44 | actors: ['actor1'], 45 | }; 46 | const processed = processInput(input); 47 | expect(processed.enableAddingActors).toBe(false); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/unit/mcp.utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { parseInputParamsFromUrl } from '../../src/mcp/utils.js'; 4 | 5 | describe('parseInputParamsFromUrl', () => { 6 | it('should parse Actors from URL query params', () => { 7 | const url = 'https://actors-mcp-server.apify.actor?token=123&actors=apify/web-scraper'; 8 | const result = parseInputParamsFromUrl(url); 9 | expect(result.actors).toEqual(['apify/web-scraper']); 10 | }); 11 | 12 | it('should parse multiple Actors from URL', () => { 13 | const url = 'https://actors-mcp-server.apify.actor?actors=apify/instagram-scraper,lukaskrivka/google-maps'; 14 | const result = parseInputParamsFromUrl(url); 15 | expect(result.actors).toEqual(['apify/instagram-scraper', 'lukaskrivka/google-maps']); 16 | }); 17 | 18 | it('should handle URL without query params', () => { 19 | const url = 'https://actors-mcp-server.apify.actor'; 20 | const result = parseInputParamsFromUrl(url); 21 | expect(result.actors).toBeUndefined(); 22 | }); 23 | 24 | it('should parse enableActorAutoLoading flag', () => { 25 | const url = 'https://actors-mcp-server.apify.actor?enableActorAutoLoading=true'; 26 | const result = parseInputParamsFromUrl(url); 27 | expect(result.enableAddingActors).toBe(true); 28 | }); 29 | 30 | it('should parse enableAddingActors flag', () => { 31 | const url = 'https://actors-mcp-server.apify.actor?enableAddingActors=true'; 32 | const result = parseInputParamsFromUrl(url); 33 | expect(result.enableAddingActors).toBe(true); 34 | }); 35 | 36 | it('should parse enableAddingActors flag', () => { 37 | const url = 'https://actors-mcp-server.apify.actor?enableAddingActors=false'; 38 | const result = parseInputParamsFromUrl(url); 39 | expect(result.enableAddingActors).toBe(false); 40 | }); 41 | 42 | it('should handle Actors as string parameter', () => { 43 | const url = 'https://actors-mcp-server.apify.actor?actors=apify/rag-web-browser'; 44 | const result = parseInputParamsFromUrl(url); 45 | expect(result.actors).toEqual(['apify/rag-web-browser']); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /tests/unit/tools.actor.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { ACTOR_ENUM_MAX_LENGTH } from '../../src/const.js'; 4 | import { actorNameToToolName, inferArrayItemType, shortenEnum } from '../../src/tools/utils.js'; 5 | 6 | describe('actors', () => { 7 | describe('actorNameToToolName', () => { 8 | it('should replace slashes and dots with dash notation', () => { 9 | expect(actorNameToToolName('apify/web-scraper')).toBe('apify-slash-web-scraper'); 10 | expect(actorNameToToolName('my.actor.name')).toBe('my-dot-actor-dot-name'); 11 | }); 12 | 13 | it('should handle empty strings', () => { 14 | expect(actorNameToToolName('')).toBe(''); 15 | }); 16 | 17 | it('should handle strings without slashes or dots', () => { 18 | expect(actorNameToToolName('actorname')).toBe('actorname'); 19 | }); 20 | 21 | it('should handle strings with multiple slashes and dots', () => { 22 | expect(actorNameToToolName('actor/name.with/multiple.parts')).toBe('actor-slash-name-dot-with-slash-multiple-dot-parts'); 23 | }); 24 | 25 | it('should handle tool names longer than 64 characters', () => { 26 | const longName = 'a'.repeat(70); 27 | const expected = 'a'.repeat(64); 28 | expect(actorNameToToolName(longName)).toBe(expected); 29 | }); 30 | 31 | it('infers array item type from editor', () => { 32 | const property = { 33 | type: 'array', 34 | editor: 'stringList', 35 | title: '', 36 | description: '', 37 | enum: [], 38 | default: '', 39 | prefill: '', 40 | }; 41 | expect(inferArrayItemType(property)).toBe('string'); 42 | }); 43 | 44 | it('shorten enum list', () => { 45 | const enumList: string[] = []; 46 | const wordLength = 10; 47 | const wordCount = 30; 48 | 49 | for (let i = 0; i < wordCount; i++) { 50 | enumList.push('a'.repeat(wordLength)); 51 | } 52 | 53 | const shortenedList = shortenEnum(enumList); 54 | 55 | expect(shortenedList?.length || 0).toBe(ACTOR_ENUM_MAX_LENGTH / wordLength); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /tests/unit/tools.utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { ACTOR_ENUM_MAX_LENGTH, ACTOR_MAX_DESCRIPTION_LENGTH } from '../../src/const.js'; 4 | import { buildNestedProperties, markInputPropertiesAsRequired, shortenProperties } from '../../src/tools/utils.js'; 5 | import type { IActorInputSchema, ISchemaProperties } from '../../src/types.js'; 6 | 7 | describe('buildNestedProperties', () => { 8 | it('should add useApifyProxy property to proxy objects', () => { 9 | const properties: Record = { 10 | proxy: { 11 | type: 'object', 12 | editor: 'proxy', 13 | title: 'Proxy configuration', 14 | description: 'Proxy settings', 15 | properties: {}, 16 | }, 17 | otherProp: { 18 | type: 'string', 19 | title: 'Other property', 20 | description: 'Some other property', 21 | }, 22 | }; 23 | 24 | const result = buildNestedProperties(properties); 25 | 26 | // Check that proxy object has useApifyProxy property 27 | expect(result.proxy.properties).toBeDefined(); 28 | expect(result.proxy.properties?.useApifyProxy).toBeDefined(); 29 | expect(result.proxy.properties?.useApifyProxy.type).toBe('boolean'); 30 | expect(result.proxy.properties?.useApifyProxy.default).toBe(true); 31 | expect(result.proxy.required).toContain('useApifyProxy'); 32 | 33 | // Check that other properties remain unchanged 34 | expect(result.otherProp).toEqual(properties.otherProp); 35 | }); 36 | 37 | it('should add URL structure to requestListSources array items', () => { 38 | const properties: Record = { 39 | sources: { 40 | type: 'array', 41 | editor: 'requestListSources', 42 | title: 'Request list sources', 43 | description: 'Sources to scrape', 44 | }, 45 | otherProp: { 46 | type: 'string', 47 | title: 'Other property', 48 | description: 'Some other property', 49 | }, 50 | }; 51 | 52 | const result = buildNestedProperties(properties); 53 | 54 | // Check that requestListSources array has proper item structure 55 | expect(result.sources.items).toBeDefined(); 56 | expect(result.sources.items?.type).toBe('object'); 57 | expect(result.sources.items?.properties?.url).toBeDefined(); 58 | expect(result.sources.items?.properties?.url.type).toBe('string'); 59 | 60 | // Check that other properties remain unchanged 61 | expect(result.otherProp).toEqual(properties.otherProp); 62 | }); 63 | 64 | it('should not modify properties that don\'t match special cases', () => { 65 | const properties: Record = { 66 | regularObject: { 67 | type: 'object', 68 | title: 'Regular object', 69 | description: 'A regular object without special editor', 70 | properties: { 71 | subProp: { 72 | type: 'string', 73 | title: 'Sub property', 74 | description: 'Sub property description', 75 | }, 76 | }, 77 | }, 78 | regularArray: { 79 | type: 'array', 80 | title: 'Regular array', 81 | description: 'A regular array without special editor', 82 | items: { 83 | type: 'string', 84 | title: 'Item', 85 | description: 'Item description', 86 | }, 87 | }, 88 | }; 89 | 90 | const result = buildNestedProperties(properties); 91 | 92 | // Check that regular properties remain unchanged 93 | expect(result).toEqual(properties); 94 | }); 95 | 96 | it('should handle empty properties object', () => { 97 | const properties: Record = {}; 98 | const result = buildNestedProperties(properties); 99 | expect(result).toEqual({}); 100 | }); 101 | }); 102 | 103 | describe('markInputPropertiesAsRequired', () => { 104 | it('should add REQUIRED prefix to required properties', () => { 105 | const input: IActorInputSchema = { 106 | title: 'Test Schema', 107 | type: 'object', 108 | required: ['requiredProp1', 'requiredProp2'], 109 | properties: { 110 | requiredProp1: { 111 | type: 'string', 112 | title: 'Required Property 1', 113 | description: 'This is required', 114 | }, 115 | requiredProp2: { 116 | type: 'number', 117 | title: 'Required Property 2', 118 | description: 'This is also required', 119 | }, 120 | optionalProp: { 121 | type: 'boolean', 122 | title: 'Optional Property', 123 | description: 'This is optional', 124 | }, 125 | }, 126 | }; 127 | 128 | const result = markInputPropertiesAsRequired(input); 129 | 130 | // Check that required properties have REQUIRED prefix 131 | expect(result.requiredProp1.description).toContain('**REQUIRED**'); 132 | expect(result.requiredProp2.description).toContain('**REQUIRED**'); 133 | 134 | // Check that optional properties remain unchanged 135 | expect(result.optionalProp.description).toBe('This is optional'); 136 | }); 137 | 138 | it('should handle input without required fields', () => { 139 | const input: IActorInputSchema = { 140 | title: 'Test Schema', 141 | type: 'object', 142 | properties: { 143 | prop1: { 144 | type: 'string', 145 | title: 'Property 1', 146 | description: 'Description 1', 147 | }, 148 | prop2: { 149 | type: 'number', 150 | title: 'Property 2', 151 | description: 'Description 2', 152 | }, 153 | }, 154 | }; 155 | 156 | const result = markInputPropertiesAsRequired(input); 157 | 158 | // Check that no properties were modified 159 | expect(result).toEqual(input.properties); 160 | }); 161 | 162 | it('should handle empty required array', () => { 163 | const input: IActorInputSchema = { 164 | title: 'Test Schema', 165 | type: 'object', 166 | required: [], 167 | properties: { 168 | prop1: { 169 | type: 'string', 170 | title: 'Property 1', 171 | description: 'Description 1', 172 | }, 173 | }, 174 | }; 175 | 176 | const result = markInputPropertiesAsRequired(input); 177 | 178 | // Check that no properties were modified 179 | expect(result).toEqual(input.properties); 180 | }); 181 | }); 182 | 183 | describe('shortenProperties', () => { 184 | it('should truncate long descriptions', () => { 185 | const longDescription = 'a'.repeat(ACTOR_MAX_DESCRIPTION_LENGTH + 100); 186 | const properties: Record = { 187 | prop1: { 188 | type: 'string', 189 | title: 'Property 1', 190 | description: longDescription, 191 | }, 192 | }; 193 | 194 | const result = shortenProperties(properties); 195 | 196 | // Check that description was truncated 197 | expect(result.prop1.description.length).toBeLessThanOrEqual(ACTOR_MAX_DESCRIPTION_LENGTH + 3); // +3 for "..." 198 | expect(result.prop1.description.endsWith('...')).toBe(true); 199 | }); 200 | 201 | it('should not modify descriptions that are within limits', () => { 202 | const description = 'This is a normal description'; 203 | const properties: Record = { 204 | prop1: { 205 | type: 'string', 206 | title: 'Property 1', 207 | description, 208 | }, 209 | }; 210 | 211 | const result = shortenProperties(properties); 212 | 213 | // Check that description was not modified 214 | expect(result.prop1.description).toBe(description); 215 | }); 216 | 217 | it('should shorten enum values if they exceed the limit', () => { 218 | // Create an enum with many values to exceed the character limit 219 | const enumValues = Array.from({ length: 50 }, (_, i) => `enum-value-${i}`); 220 | const properties: Record = { 221 | prop1: { 222 | type: 'string', 223 | title: 'Property 1', 224 | description: 'Property with enum', 225 | enum: enumValues, 226 | }, 227 | }; 228 | 229 | const result = shortenProperties(properties); 230 | 231 | // Check that enum was shortened 232 | expect(result.prop1.enum).toBeDefined(); 233 | expect(result.prop1.enum!.length).toBeLessThan(enumValues.length); 234 | 235 | // Calculate total character length of enum values 236 | const totalLength = result.prop1.enum!.reduce((sum, val) => sum + val.length, 0); 237 | expect(totalLength).toBeLessThanOrEqual(ACTOR_ENUM_MAX_LENGTH); 238 | }); 239 | 240 | it('should shorten items.enum values if they exceed the limit', () => { 241 | // Create an enum with many values to exceed the character limit 242 | const enumValues = Array.from({ length: 50 }, (_, i) => `enum-value-${i}`); 243 | const properties: Record = { 244 | prop1: { 245 | type: 'array', 246 | title: 'Property 1', 247 | description: 'Property with items.enum', 248 | items: { 249 | type: 'string', 250 | title: 'Item', 251 | description: 'Item description', 252 | enum: enumValues, 253 | }, 254 | }, 255 | }; 256 | 257 | const result = shortenProperties(properties); 258 | 259 | // Check that items.enum was shortened 260 | expect(result.prop1.items?.enum).toBeDefined(); 261 | expect(result.prop1.items!.enum!.length).toBeLessThan(enumValues.length); 262 | 263 | // Calculate total character length of enum values 264 | const totalLength = result.prop1.items!.enum!.reduce((sum, val) => sum + val.length, 0); 265 | expect(totalLength).toBeLessThanOrEqual(ACTOR_ENUM_MAX_LENGTH); 266 | }); 267 | 268 | it('should handle properties without enum or items.enum', () => { 269 | const properties: Record = { 270 | prop1: { 271 | type: 'string', 272 | title: 'Property 1', 273 | description: 'Regular property', 274 | }, 275 | prop2: { 276 | type: 'array', 277 | title: 'Property 2', 278 | description: 'Array property', 279 | items: { 280 | type: 'string', 281 | title: 'Item', 282 | description: 'Item description', 283 | }, 284 | }, 285 | }; 286 | 287 | const result = shortenProperties(properties); 288 | 289 | // Check that properties were not modified 290 | expect(result).toEqual(properties); 291 | }); 292 | 293 | it('should handle empty enum arrays', () => { 294 | const properties: Record = { 295 | prop1: { 296 | type: 'string', 297 | title: 'Property 1', 298 | description: 'Property with empty enum', 299 | enum: [], 300 | }, 301 | prop2: { 302 | type: 'array', 303 | title: 'Property 2', 304 | description: 'Array with empty items.enum', 305 | items: { 306 | type: 'string', 307 | title: 'Item', 308 | description: 'Item description', 309 | enum: [], 310 | }, 311 | }, 312 | }; 313 | 314 | const result = shortenProperties(properties); 315 | 316 | // Check that properties were not modified 317 | expect(result).toEqual(properties); 318 | }); 319 | }); 320 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src", 5 | "test", 6 | "tests", 7 | "vitest.config.ts" 8 | ], 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@apify/tsconfig", 3 | "compilerOptions": { 4 | "module": "ES2022", 5 | "skipLibCheck": true, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/extensions 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | // eslint-disable-next-line import/no-default-export 5 | export default defineConfig({ 6 | test: { 7 | globals: true, 8 | environment: 'node', 9 | include: ['tests/**/*.test.ts'], 10 | testTimeout: 120_000, 11 | }, 12 | }); 13 | --------------------------------------------------------------------------------