├── .distignore
├── .editorconfig
├── .github
├── ISSUE_TEMPLATE
├── PULL_REQUEST_TEMPLATE
└── workflows
│ ├── code-quality.yml
│ └── testing.yml
├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── ai-command.php
├── behat.yml
├── composer.json
├── features
├── ai.feature
└── mcp-server.feature
├── phpcs.xml.dist
├── phpstan.neon.dist
├── phpunit.xml.dist
├── settings
└── route-additions.php
├── src
├── AI
│ └── AiClient.php
├── AiCommand.php
├── MCP
│ ├── Client.php
│ ├── HttpTransport.php
│ ├── InMemorySession.php
│ ├── InMemoryTransport.php
│ └── Servers
│ │ └── WP_CLI
│ │ ├── Tools
│ │ └── CliCommands.php
│ │ └── WP_CLI.php
├── McpServerCommand.php
└── Utils
│ ├── CliLogger.php
│ └── McpConfig.php
├── tests
├── phpstan
│ └── bootstrap.php
└── phpunit
│ └── tests
│ └── MCP
│ └── Client
│ └── ClientTest.php
└── wp-cli.yml
/.distignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .git
3 | .gitignore
4 | .gitlab-ci.yml
5 | .editorconfig
6 | .travis.yml
7 | behat.yml
8 | circle.yml
9 | phpcs.xml.dist
10 | phpunit.xml.dist
11 | bin/
12 | features/
13 | utils/
14 | *.zip
15 | *.tar.gz
16 | *.swp
17 | *.txt
18 | *.log
19 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # This file is for unifying the coding style for different editors and IDEs
2 | # editorconfig.org
3 |
4 | # WordPress Coding Standards
5 | # https://make.wordpress.org/core/handbook/coding-standards/
6 |
7 | # From https://github.com/WordPress/wordpress-develop/blob/trunk/.editorconfig with a couple of additions.
8 |
9 | root = true
10 |
11 | [*]
12 | charset = utf-8
13 | end_of_line = lf
14 | insert_final_newline = true
15 | trim_trailing_whitespace = true
16 | indent_style = tab
17 |
18 | [{*.yml,*.feature,.jshintrc,*.json}]
19 | indent_style = space
20 | indent_size = 2
21 |
22 | [*.md]
23 | trim_trailing_whitespace = false
24 |
25 | [{*.txt,wp-config-sample.php}]
26 | end_of_line = crlf
27 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/.github/workflows/code-quality.yml:
--------------------------------------------------------------------------------
1 | name: Code Quality Checks
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | concurrency:
10 | group: ${{ github.workflow }}-${{ github.ref }}
11 | cancel-in-progress: true
12 |
13 | jobs:
14 | actionlint:
15 | name: Lint GitHub Actions workflows
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Check out source code
19 | uses: actions/checkout@v4
20 |
21 | - name: Add problem matcher
22 | run: |
23 | curl -s -o .github/actionlint-matcher.json https://raw.githubusercontent.com/rhysd/actionlint/main/.github/actionlint-matcher.json
24 | echo "::add-matcher::.github/actionlint-matcher.json"
25 |
26 | - name: Check workflow files
27 | uses: docker://rhysd/actionlint:latest
28 | with:
29 | args: -color -shellcheck=
30 |
31 | lint:
32 | name: Lint PHP files
33 | runs-on: ubuntu-latest
34 | steps:
35 | - name: Check out source code
36 | uses: actions/checkout@v4
37 |
38 | - name: Set up PHP environment
39 | uses: shivammathur/setup-php@v2
40 | with:
41 | php-version: 'latest'
42 | ini-values: zend.assertions=1, error_reporting=-1, display_errors=On
43 | tools: cs2pr
44 | env:
45 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46 |
47 | - name: Install Composer dependencies & cache dependencies
48 | uses: "ramsey/composer-install@v3"
49 | env:
50 | COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }}
51 | with:
52 | # Bust the cache at least once a month - output format: YYYY-MM.
53 | custom-cache-suffix: $(date -u "+%Y-%m")
54 |
55 | - name: Run Linter
56 | run: vendor/bin/parallel-lint -j 10 . --show-deprecated --exclude vendor --exclude .git --exclude third-party --checkstyle | cs2pr
57 |
58 | phpcs:
59 | name: PHPCS
60 | runs-on: ubuntu-latest
61 |
62 | steps:
63 | - name: Check out source code
64 | uses: actions/checkout@v4
65 |
66 | - name: Set up PHP environment
67 | uses: shivammathur/setup-php@v2
68 | with:
69 | php-version: 'latest'
70 | tools: cs2pr
71 | env:
72 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
73 |
74 | - name: Install Composer dependencies & cache dependencies
75 | uses: "ramsey/composer-install@v3"
76 | env:
77 | COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }}
78 | with:
79 | # Bust the cache at least once a month - output format: YYYY-MM.
80 | custom-cache-suffix: $(date -u "+%Y-%m")
81 |
82 | - name: Run PHPCS
83 | run: vendor/bin/phpcs -q --report=checkstyle | cs2pr --graceful-warnings
84 |
--------------------------------------------------------------------------------
/.github/workflows/testing.yml:
--------------------------------------------------------------------------------
1 | name: Testing
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | concurrency:
10 | group: ${{ github.workflow }}-${{ github.ref }}
11 | cancel-in-progress: true
12 |
13 | jobs:
14 | behat:
15 | name: Functional / PHP ${{ matrix.php }}
16 | strategy:
17 | matrix:
18 | php: ['8.2']
19 | wp: ['latest']
20 | coverage: [true]
21 | runs-on: ubuntu-latest
22 | services:
23 | mysql:
24 | image: mysql:8
25 | env:
26 | MYSQL_ALLOW_EMPTY_PASSWORD: yes
27 | MYSQL_DATABASE: wp_cli_test
28 | MYSQL_USER: wp_cli_test
29 | MYSQL_PASSWORD: password1
30 | MYSQL_HOST: 127.0.0.1
31 | ports:
32 | - 3306
33 | options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
34 | steps:
35 | - name: Check out source code
36 | uses: actions/checkout@v4
37 |
38 | - name: Setup PHP
39 | uses: shivammathur/setup-php@v2
40 | with:
41 | php-version: ${{ matrix.php }}
42 | coverage: ${{ matrix.coverage && 'xdebug' || 'none' }}
43 | tools: composer
44 |
45 | - name: Install composer packages
46 | run: composer install
47 |
48 | - name: Check Behat environment
49 | env:
50 | WP_VERSION: '${{ matrix.wp }}'
51 | WP_CLI_TEST_DBUSER: wp_cli_test
52 | WP_CLI_TEST_DBPASS: password1
53 | WP_CLI_TEST_DBNAME: wp_cli_test
54 | WP_CLI_TEST_DBHOST: 127.0.0.1:${{ job.services.mysql.ports['3306'] }}
55 | run: WP_CLI_TEST_DEBUG_BEHAT_ENV=1 composer behat
56 |
57 | - name: Run Behat
58 | env:
59 | WP_VERSION: '${{ matrix.wp }}'
60 | WP_CLI_TEST_DBUSER: wp_cli_test
61 | WP_CLI_TEST_DBPASS: password1
62 | WP_CLI_TEST_DBNAME: wp_cli_test
63 | WP_CLI_TEST_DBHOST: 127.0.0.1:${{ job.services.mysql.ports['3306'] }}
64 | WP_CLI_TEST_COVERAGE: ${{ matrix.coverage }}
65 | run: |
66 | ARGS=()
67 |
68 | if [[ $WP_CLI_TEST_COVERAGE == 'true' ]]; then
69 | ARGS+=("--xdebug")
70 | fi
71 |
72 | if [[ $RUNNER_DEBUG == '1' ]]; then
73 | ARGS+=("--format=pretty")
74 | fi
75 |
76 | composer behat -- "${ARGS[@]}" || composer behat-rerun -- "${ARGS[@]}"
77 |
78 | - name: Retrieve list of coverage files
79 | id: coverage_files
80 | if: ${{ matrix.coverage }}
81 | run: |
82 | FILES=$(find "$GITHUB_WORKSPACE/build/logs" -path '*.*' | paste -s -d "," -)
83 | echo "files=$FILES" >> $GITHUB_OUTPUT
84 |
85 | - name: Upload code coverage report
86 | if: ${{ matrix.coverage }}
87 | uses: codecov/codecov-action@v5.4.0
88 | with:
89 | # Because somehow providing `directory: build/logs` doesn't work for these files
90 | files: ${{ steps.coverage_files.outputs.files }}
91 | flags: feature
92 | token: ${{ secrets.CODECOV_TOKEN }}
93 |
94 | unit: #-----------------------------------------------------------------------
95 | name: Unit test / PHP ${{ matrix.php }}
96 | strategy:
97 | matrix:
98 | php: [ '8.2' ]
99 | coverage: [ false ]
100 | include:
101 | - php: '8.3'
102 | coverage: true
103 | runs-on: ubuntu-latest
104 |
105 | steps:
106 | - name: Check out source code
107 | uses: actions/checkout@v4
108 |
109 | - name: Set up PHP environment
110 | uses: shivammathur/setup-php@v2
111 | with:
112 | php-version: '${{ matrix.php }}'
113 | ini-values: zend.assertions=1, error_reporting=-1, display_errors=On
114 | coverage: ${{ matrix.coverage && 'xdebug' || 'none' }}
115 | tools: composer,cs2pr
116 |
117 | - name: Install Composer dependencies & cache dependencies
118 | uses: ramsey/composer-install@v3
119 | env:
120 | COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }}
121 | with:
122 | # Bust the cache at least once a month - output format: YYYY-MM.
123 | custom-cache-suffix: $(date -u "+%Y-%m")
124 |
125 | - name: Grab PHPUnit version
126 | id: phpunit_version
127 | run: echo "VERSION=$(vendor/bin/phpunit --version | grep --only-matching --max-count=1 --extended-regexp '\b[0-9]+\.[0-9]+')" >> $GITHUB_OUTPUT
128 |
129 | # PHPUnit 10 may fail a test run when the "old" configuration format is used.
130 | # Luckily, there is a build-in migration tool since PHPUnit 9.3.
131 | - name: Migrate PHPUnit configuration for PHPUnit 10+
132 | if: ${{ startsWith( steps.phpunit_version.outputs.VERSION, '10.' ) }}
133 | continue-on-error: true
134 | run: composer phpunit -- --migrate-configuration
135 |
136 | - name: Setup problem matcher to provide annotations for PHPUnit
137 | run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
138 |
139 | - name: Run PHPUnit
140 | run: |
141 | if [[ ${{ matrix.coverage == true }} == true ]]; then
142 | composer phpunit -- --coverage-clover build/logs/unit-coverage.xml
143 | else
144 | composer phpunit
145 | fi
146 |
147 | - name: Upload code coverage report
148 | if: ${{ matrix.coverage }}
149 | uses: codecov/codecov-action@v5.4.0
150 | with:
151 | directory: build/logs
152 | flags: unit
153 | token: ${{ secrets.CODECOV_TOKEN }}
154 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode/
2 | vendor/
3 | composer.lock
4 | phpcs.xml
5 | phpunit.xml
6 | .phpunit.result.cache
7 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | Contributing
2 | ============
3 |
4 | We appreciate you taking the initiative to contribute to this project.
5 |
6 | Contributing isn’t limited to just code. We encourage you to contribute in the way that best fits your abilities, by writing tutorials, giving a demo at your local meetup, helping other users with their support questions, or revising our documentation.
7 |
8 | For a more thorough introduction, [check out WP-CLI's guide to contributing](https://make.wordpress.org/cli/handbook/contributing/). This package follows those policy and guidelines.
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
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 [yyyy] [name of copyright owner]
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # WP-CLI AI Command with MCP support
2 |
3 | [](https://github.com/mcp-wp/ai-command/pulse/monthly)
4 | [](https://codecov.io/gh/mcp-wp/ai-command)
5 | [](https://github.com/mcp-wp/ai-command/blob/main/LICENSE)
6 |
7 | This WP-CLI command enables direct AI interactions with WordPress installations during development by implementing the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP).
8 | It not only provides its own MCP server for controlling WordPress sites, but also allows connecting to any other local or remote MCP server.
9 |
10 | [](https://mcp-wp.github.io/)
11 |
12 | ## Installing
13 |
14 | Installing this package requires WP-CLI v2.11 or greater. Update to the latest stable release with `wp cli update`.
15 |
16 | **Tip:** for better support of the latest PHP versions, use the v2.12 nightly build with `wp cli update --nightly`.
17 |
18 | To install the latest development version of this package, use the following command instead:
19 |
20 | ```bash
21 | wp package install mcp-wp/ai-command:dev-main
22 | ```
23 |
24 | Right now, the plugin requires a WordPress site with the [AI Services plugin](https://wordpress.org/plugins/ai-services) installed.
25 |
26 | ### Reporting a bug
27 |
28 | Think you’ve found a bug? We’d love for you to help us get it fixed.
29 |
30 | Before you create a new issue, you should [search existing issues](https://github.com/mcp-wp/ai-command/issues?q=label%3Abug%20) to see if there’s an existing resolution to it, or if it’s already been fixed in a newer version.
31 |
32 | Once you’ve done a bit of searching and discovered there isn’t an open or fixed issue for your bug, please [create a new issue](https://github.com/mcp-wp/ai-command/issues/new). Include as much detail as you can, and clear steps to reproduce if possible. For more guidance, [review our bug report documentation](https://make.wordpress.org/cli/handbook/bug-reports/).
33 |
34 | ### Creating a pull request
35 |
36 | Want to contribute a new feature? Please first [open a new issue](https://github.com/mcp-wp/ai-command/issues/new) to discuss whether the feature is a good fit for the project.
37 |
38 | Once you've decided to commit the time to seeing your pull request through, [please follow our guidelines for creating a pull request](https://make.wordpress.org/cli/handbook/pull-requests/) to make sure it's a pleasant experience.
39 |
--------------------------------------------------------------------------------
/ai-command.php:
--------------------------------------------------------------------------------
1 |
2 |
3 | Custom ruleset for ai-command
4 |
5 |
12 |
13 |
14 | .
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
30 |
31 | -
32 |
33 |
38 |
39 |
41 |
42 |
43 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | scoper.inc.php
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | */third-party/*
66 | */vendor/*
67 | */includes/vendor/*
68 |
69 |
--------------------------------------------------------------------------------
/phpstan.neon.dist:
--------------------------------------------------------------------------------
1 | parameters:
2 | level: 8
3 | paths:
4 | - ai-command.php
5 | - src/
6 | scanDirectories:
7 | - vendor/wpackagist-plugin/ai-services/includes
8 | - vendor/wpackagist-plugin/ai-services/third-party
9 | - vendor/wp-cli/wp-cli/bundle/rmccue/requests
10 | - vendor/wp-cli/wp-cli/php
11 | bootstrapFiles:
12 | - tests/phpstan/bootstrap.php
13 | reportMaybesInMethodSignatures: false
14 | strictRules:
15 | disallowedEmpty: false
16 | strictArrayFilter: false
17 | includes:
18 | - phar://phpstan.phar/conf/bleedingEdge.neon
19 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 | tests/phpunit/tests
18 |
19 |
20 |
21 |
22 |
23 | src
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/settings/route-additions.php:
--------------------------------------------------------------------------------
1 | [
5 | 'POST' => [
6 | 'defaults' => [
7 | 'status' => 'private',
8 | ],
9 | ],
10 | ],
11 | ];
12 |
--------------------------------------------------------------------------------
/src/AI/AiClient.php:
--------------------------------------------------------------------------------
1 | >, server: string, callback: callable}
24 | */
25 | class AiClient {
26 | private bool $needs_approval = true;
27 |
28 | /**
29 | * @param array $tools List of tools.
30 | * @param bool $approval_mode Whether tool usage needs to be approved.
31 | * @param string|null $model Model to use.
32 | *
33 | * @phpstan-param ToolDefinition[] $tools
34 | */
35 | public function __construct(
36 | private readonly array $tools,
37 | private readonly bool $approval_mode,
38 | private readonly ?string $service,
39 | private readonly ?string $model
40 | ) {}
41 |
42 | /**
43 | * Calls a given tool.
44 | *
45 | * @param string $tool_name Tool name.
46 | * @param mixed $tool_args Tool args.
47 | * @return mixed
48 | */
49 | private function call_tool( string $tool_name, mixed $tool_args ): mixed {
50 | foreach ( $this->tools as $tool ) {
51 | if ( $tool_name === $tool['name'] ) {
52 | return call_user_func( $tool['callback'], $tool_args );
53 | }
54 | }
55 |
56 | throw new InvalidArgumentException( 'Tool "' . $tool_name . '" not found.' );
57 | }
58 |
59 | /**
60 | * Returns the name of the server a given tool is coming from.
61 | *
62 | * @param string $tool_name Tool name.
63 | * @return mixed
64 | */
65 | private function get_tool_server_name( string $tool_name ): mixed {
66 | foreach ( $this->tools as $tool ) {
67 | if ( $tool_name === $tool['name'] ) {
68 | return $tool['server'];
69 | }
70 | }
71 |
72 | throw new InvalidArgumentException( 'Tool "' . $tool_name . '" not found.' );
73 | }
74 |
75 | public function call_ai_service_with_prompt( string $prompt ): void {
76 | $parts = new Parts();
77 | $parts->add_text_part( $prompt );
78 | $content = new Content( Content_Role::USER, $parts );
79 |
80 | $this->call_ai_service( [ $content ] );
81 | }
82 |
83 | /**
84 | * Calls AI service with given contents.
85 | *
86 | * @param Content[] $contents Contents to send to AI.
87 | * @return void
88 | */
89 | private function call_ai_service( $contents ): void {
90 | // See https://github.com/felixarntz/ai-services/issues/25.
91 | // Temporarily ignore error because eventually this should not be needed anymore.
92 | // @phpstan-ignore function.notFound
93 | add_filter(
94 | 'map_meta_cap',
95 | static function () {
96 | return [ 'exist' ];
97 | }
98 | );
99 |
100 | $new_contents = $contents;
101 |
102 | try {
103 | $service = ai_services()->get_available_service(
104 | [
105 | 'slugs' => $this->service ? [ $this->service ] : null,
106 | 'capabilities' => [
107 | AI_Capability::MULTIMODAL_INPUT,
108 | AI_Capability::TEXT_GENERATION,
109 | AI_Capability::FUNCTION_CALLING,
110 | ],
111 | ]
112 | );
113 |
114 | $all_tools = $this->tools;
115 |
116 | $tools = new Tools();
117 |
118 | if ( ! empty( $all_tools ) ) {
119 | if ( 'openai' === $service->get_service_slug() ) {
120 | $all_tools = array_slice( $all_tools, 0, 128 );
121 | } elseif ( 'google' === $service->get_service_slug() ) {
122 | $all_tools = array_slice( $all_tools, 0, 512 );
123 | }
124 |
125 | $tools->add_function_declarations_tool( $all_tools );
126 | }
127 |
128 | /**
129 | * Text generation model.
130 | *
131 | * @var With_Text_Generation&Generative_AI_Model $model
132 | */
133 | $model = $service
134 | ->get_model(
135 | [
136 | 'feature' => 'text-generation',
137 | 'model' => $this->model,
138 | 'tools' => $tools,
139 | 'capabilities' => [
140 | AI_Capability::MULTIMODAL_INPUT,
141 | AI_Capability::TEXT_GENERATION,
142 | AI_Capability::FUNCTION_CALLING,
143 | ],
144 | ],
145 | [
146 | 'options' => [
147 | 'timeout' => 6000,
148 | ],
149 | ]
150 | );
151 |
152 | $candidates = $model->generate_text( $contents );
153 |
154 | $text = '';
155 |
156 | $parts = $candidates->get( 0 )->get_content()?->get_parts() ?? new Parts();
157 |
158 | foreach ( $parts as $part ) {
159 | if ( $part instanceof Text_Part ) {
160 | if ( '' !== $text ) {
161 | $text .= "\n\n";
162 | }
163 | $text .= $part->get_text();
164 | } elseif ( $part instanceof Function_Call_Part ) {
165 | WP_CLI::debug( "Suggesting tool call: '{$part->get_name()}'.", 'ai-command' );
166 |
167 | // Need to repeat the function call part.
168 | $parts = new Parts();
169 | $parts->add_function_call_part( $part->get_id(), $part->get_name(), $part->get_args() );
170 | $new_contents[] = new Content( Content_Role::MODEL, $parts );
171 |
172 | $can_call_tool = true;
173 |
174 | if ( $this->approval_mode && $this->needs_approval ) {
175 | WP_CLI::line(
176 | WP_CLI::colorize(
177 | sprintf(
178 | "Run tool \"%%b%s%%n\" from \"%%b%s%%n\"?\n%%yNote:%%n Running tools from untrusted servers could have unintended consequences. Review each action carefully before approving.",
179 | $part->get_name(),
180 | $this->get_tool_server_name( $part->get_name() )
181 | )
182 | )
183 | );
184 | $result = menu(
185 | [
186 | 'y' => 'Allow once',
187 | 'a' => 'Always allow',
188 | 'n' => 'Deny once',
189 | ],
190 | 'y',
191 | 'Run tool? Choose between 1-3',
192 | );
193 |
194 | if ( 'n' === $result ) {
195 | $can_call_tool = false;
196 | } elseif ( 'a' === $result ) {
197 | $this->needs_approval = false;
198 | }
199 | }
200 |
201 | if ( $can_call_tool ) {
202 | $function_result = $this->call_tool(
203 | $part->get_name(),
204 | $part->get_args()
205 | );
206 |
207 | // Debugging.
208 | // TODO: Need to figure out correct format so LLM picks it up.
209 | $function_result = [
210 | 'name' => $part->get_name(),
211 | 'content' => $function_result['text'],
212 | ];
213 |
214 | WP_CLI::debug( "Called the '{$part->get_name()}' tool.", 'ai-command' );
215 |
216 | $parts = new Parts();
217 | $parts->add_function_response_part( $part->get_id(), $part->get_name(), $function_result );
218 | $content = new Content( Content_Role::USER, $parts );
219 | $new_contents[] = $content;
220 | } else {
221 | WP_CLI::debug( "Function call denied:'{$part->get_name()}'.", 'ai-command' );
222 | }
223 | }
224 | }
225 |
226 | if ( $new_contents !== $contents ) {
227 | $this->call_ai_service( $new_contents );
228 | return;
229 | }
230 |
231 | // Keep the session open to continue chatting.
232 |
233 | WP_CLI::line( WP_CLI::colorize( "%G$text%n " ) );
234 |
235 | $response = prompt( '', false, '' );
236 |
237 | $parts = new Parts();
238 | $parts->add_text_part( $response );
239 | $content = new Content( Content_Role::USER, $parts );
240 | $new_contents[] = $content;
241 | $this->call_ai_service( $new_contents );
242 | return;
243 | } catch ( Exception $e ) {
244 | WP_CLI::error( $e->getMessage() );
245 | }
246 | }
247 | }
248 |
--------------------------------------------------------------------------------
/src/AiCommand.php:
--------------------------------------------------------------------------------
1 |
31 | * : AI prompt.
32 | *
33 | * [--skip-builtin-servers[=]]
34 | * : Skip loading the built-in servers for WP-CLI and the current WordPress site.
35 | * Can be set to 'all' (skip both), 'cli' (skip the WP-CLI server),
36 | * or 'wp' (skip the WordPress server).
37 | *
38 | * [--skip-wordpress]
39 | * : Run command without loading WordPress. (Not implemented yet)
40 | *
41 | * [--approval-mode]
42 | * : Approve tool usage before running.
43 | *
44 | * [--service=]
45 | * : Manually specify the AI service to use.
46 | * Depends on the available AI services.
47 | * Examples: 'google', 'anthropic', 'openai'.
48 | *
49 | * [--model=]
50 | * : Manually specify the LLM model that should be used.
51 | * Depends on the available AI services.
52 | * Examples: 'gemini-2.0-flash', 'gpt-4o'.
53 | *
54 | * ## EXAMPLES
55 | *
56 | * # Get data from WordPress
57 | * $ wp ai "What are the titles of my last three posts?"
58 | * - Hello world
59 | * - My awesome post
60 | * - Another post
61 | *
62 | * # Interact with multiple MCP servers.
63 | * $ wp ai "Take file foo.txt and create a new blog post from it"
64 | * Success: Blog post created.
65 | *
66 | * @when before_wp_load
67 | *
68 | * @param string[] $args Indexed array of positional arguments.
69 | * @param array $assoc_args Associative arguments.
70 | */
71 | public function __invoke( array $args, array $assoc_args ): void {
72 | $with_wordpress = null === Utils\get_flag_value( $assoc_args, 'skip-wordpress' );
73 | if ( $with_wordpress ) {
74 | WP_CLI::get_runner()->load_wordpress();
75 | } else {
76 | WP_CLI::error( 'Not implemented yet.' );
77 | }
78 |
79 | if ( ! function_exists( '\ai_services' ) ) {
80 | WP_CLI::error( 'This command currently requires the AI Services plugin. You can install it with `wp plugin install ai-services --activate`.' );
81 | }
82 |
83 | $skip_builtin_servers = Utils\get_flag_value( $assoc_args, 'skip-builtin-servers', 'all' );
84 |
85 | $sessions = $this->get_sessions( $with_wordpress && 'cli' === $skip_builtin_servers, 'wp' === $skip_builtin_servers );
86 | $tools = $this->get_tools( $sessions );
87 |
88 | $approval_mode = (bool) Utils\get_flag_value( $assoc_args, 'approval-mode', false );
89 | $service = Utils\get_flag_value( $assoc_args, 'service' );
90 | $model = Utils\get_flag_value( $assoc_args, 'model' );
91 |
92 | $ai_client = new AiClient( $tools, $approval_mode, $service, $model );
93 |
94 | $ai_client->call_ai_service_with_prompt( $args[0] );
95 | }
96 |
97 | /**
98 | * Returns a combined list of all tools for all existing MCP client sessions.
99 | *
100 | * @param array $sessions List of available sessions.
101 | * @return array List of tools.
102 | *
103 | * @phpstan-return ToolDefinition[]
104 | */
105 | protected function get_tools( array $sessions ): array {
106 | $function_declarations = [];
107 |
108 | foreach ( $sessions as $name => $session ) {
109 | foreach ( $session->listTools()->tools as $mcp_tool ) {
110 | $parameters = json_decode(
111 | json_encode( $mcp_tool->inputSchema->jsonSerialize(), JSON_THROW_ON_ERROR ),
112 | true,
113 | 512,
114 | JSON_THROW_ON_ERROR
115 | );
116 | unset( $parameters['additionalProperties'], $parameters['$schema'] );
117 |
118 | // Not having any properties doesn't seem to work.
119 | if ( empty( $parameters['properties'] ) ) {
120 | $parameters['properties'] = [
121 | 'dummy' => [
122 | 'type' => 'string',
123 | ],
124 | ];
125 | }
126 |
127 | // FIXME: had some issues with the inputSchema here.
128 | if ( 'edit_file' === $mcp_tool->name || 'search_files' === $mcp_tool->name ) {
129 | continue;
130 | }
131 |
132 | $function_declarations[] = [
133 | 'name' => $mcp_tool->name,
134 | 'description' => $mcp_tool->description,
135 | 'parameters' => $parameters,
136 | 'server' => $name,
137 | 'callback' => static function ( mixed $tool_args ) use ( $mcp_tool, $session ) {
138 | $result = $session->callTool( $mcp_tool->name, $tool_args );
139 | // TODO: Convert ImageContent or EmbeddedResource into Blob?
140 |
141 | // To trigger the jsonSerialize() methods.
142 | // TODO: Return all array items, not just first one.
143 | return json_decode(
144 | json_encode( $result->content[0], JSON_THROW_ON_ERROR ),
145 | true,
146 | 512,
147 | JSON_THROW_ON_ERROR
148 | );
149 | },
150 | ];
151 | }
152 | }
153 |
154 | return $function_declarations;
155 | }
156 |
157 | /**
158 | * Returns a list of MCP client sessions for each MCP server that is configured.
159 | *
160 | * @param bool $with_wp_server Whether a session for the built-in WordPress MCP server should be created.
161 | * @param bool $with_cli_server Whether a session for the built-in WP-CLI MCP server should be created.
162 | * @return ClientSession[]
163 | */
164 | public function get_sessions( bool $with_wp_server, bool $with_cli_server ): array {
165 | $logger = new CliLogger();
166 |
167 | $sessions = [];
168 |
169 | if ( $with_cli_server ) {
170 | $sessions['current_site'] = ( new Client( $logger ) )->connect(
171 | MCP\Servers\WP_CLI\WP_CLI::class
172 | );
173 | }
174 |
175 | if ( $with_wp_server ) {
176 | $sessions['wp_cli'] = ( new Client( $logger ) )->connect(
177 | WordPress::class
178 | );
179 | }
180 |
181 | $servers = ( new McpConfig() )->get_servers();
182 |
183 | foreach ( $servers as $args ) {
184 | if ( 'active' !== $args['status'] ) {
185 | continue;
186 | }
187 |
188 | $server = $args['server'];
189 |
190 | if ( str_starts_with( $server, 'http://' ) || str_starts_with( $server, 'https://' ) ) {
191 | $sessions[] = ( new Client( $logger ) )->connect(
192 | $server
193 | );
194 | continue;
195 | }
196 |
197 | $server = explode( ' ', $server );
198 | $cmd_or_url = array_shift( $server );
199 |
200 | $sessions[ $args['name'] ] = ( new Client( $logger ) )->connect(
201 | $cmd_or_url,
202 | $server,
203 | );
204 | }
205 |
206 | return $sessions;
207 | }
208 | }
209 |
--------------------------------------------------------------------------------
/src/MCP/Client.php:
--------------------------------------------------------------------------------
1 | logger = $logger ?? new NullLogger();
22 |
23 | parent::__construct( $this->logger );
24 | }
25 |
26 | /**
27 | * @param string|class-string $command_or_url Class name, command, or URL.
28 | * @param array $args Unused.
29 | * @param array|null $env Unused.
30 | * @param float|null $read_timeout Unused.
31 | * @return ClientSession
32 | */
33 | public function connect(
34 | string $command_or_url,
35 | array $args = [],
36 | ?array $env = null,
37 | ?float $read_timeout = null
38 | ): ClientSession {
39 | if ( class_exists( $command_or_url ) ) {
40 | /**
41 | * @var Server $server
42 | */
43 | $server = new $command_or_url( $this->logger );
44 |
45 | $transport = new InMemoryTransport(
46 | $server,
47 | $this->logger
48 | );
49 |
50 | [$read_stream, $write_stream] = $transport->connect();
51 |
52 | $session = new InMemorySession(
53 | $read_stream,
54 | $write_stream,
55 | $this->logger
56 | );
57 |
58 | $session->initialize();
59 |
60 | return $session;
61 | }
62 |
63 | // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url
64 | $url_parts = parse_url( $command_or_url );
65 |
66 | if (
67 | isset( $url_parts['scheme'], $url_parts['host'] ) && in_array( strtolower( $url_parts['scheme'] ), [ 'http', 'https' ], true )
68 | ) {
69 | $options = [
70 | // Just for local debugging.
71 | 'verify' => false,
72 | ];
73 | if ( ! empty( $url_parts['user'] ) && ! empty( $url_parts['pass'] ) ) {
74 | $options['auth'] = [ $url_parts['user'], $url_parts['pass'] ];
75 | }
76 |
77 | $url = $url_parts['scheme'] . '://' . $url_parts['host'] . ( $url_parts['path'] ?? '' );
78 |
79 | $transport = new HttpTransport( $url, $options, $this->logger );
80 |
81 | [$read_stream, $write_stream] = $transport->connect();
82 |
83 | // Initialize the client session with the obtained streams
84 | $session = new InMemorySession(
85 | $read_stream,
86 | $write_stream,
87 | $this->logger
88 | );
89 |
90 | // Initialize the session (e.g., perform handshake if necessary)
91 | $session->initialize();
92 | $this->logger->info( 'Session initialized successfully' );
93 |
94 | return $session;
95 | }
96 |
97 | return parent::connect( $command_or_url, $args, $env, $read_timeout );
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/MCP/HttpTransport.php:
--------------------------------------------------------------------------------
1 | $options Requests options.
34 | * @param LoggerInterface|null $logger PSR-3 compliant logger.
35 | *
36 | * @throws InvalidArgumentException If the URL is empty.
37 | */
38 | public function __construct(
39 | private readonly string $url,
40 | private readonly array $options = [],
41 | ?LoggerInterface $logger = null,
42 | ) {
43 | if ( empty( $url ) ) {
44 | throw new InvalidArgumentException( 'URL cannot be empty' );
45 | }
46 | $this->logger = $logger ?? new NullLogger();
47 | }
48 |
49 | /**
50 | * @return array{0: MemoryStream, 1: MemoryStream}
51 | */
52 | public function connect(): array {
53 | $shared_stream = new class($this->url,$this->options, $this->logger) extends MemoryStream {
54 | private LoggerInterface $logger;
55 |
56 | private ?string $session_id = null;
57 |
58 | /**
59 | * @param string $url URL to connect to.
60 | * @param array $options Requests options.
61 | * @param LoggerInterface $logger PSR-3 compliant logger.
62 | */
63 | public function __construct( private readonly string $url, private readonly array $options, LoggerInterface $logger ) {
64 | $this->logger = $logger;
65 | }
66 |
67 | /**
68 | * Send a JsonRpcMessage or Exception to the server via SSE.
69 | *
70 | * @param JsonRpcMessage|Exception $item The JSON-RPC message or exception to send.
71 | *
72 | * @return void
73 | *
74 | * @throws InvalidArgumentException If the message is not a JsonRpcMessage.
75 | * @throws RuntimeException If sending the message fails.
76 | */
77 | public function send( mixed $item ): void {
78 | if ( ! $item instanceof JsonRpcMessage ) {
79 | throw new InvalidArgumentException( 'Only JsonRpcMessage instances can be sent.' );
80 | }
81 |
82 | /**
83 | * @var Response $response
84 | */
85 | $response = \WP_CLI\Utils\http_request(
86 | 'POST',
87 | $this->url,
88 | // Wrong PHPDoc in Requests?
89 | // @phpstan-ignore argument.type
90 | json_encode( $item, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES ),
91 | [
92 | 'Content-Type' => 'application/json',
93 | 'Mcp-Session-Id' => $this->session_id,
94 | ],
95 | $this->options
96 | );
97 |
98 | if ( isset( $response->headers['mcp-session-id'] ) && ! isset( $this->session_id ) ) {
99 | $this->session_id = $response->headers['mcp-session-id'];
100 | }
101 |
102 | if ( empty( $response->body ) ) {
103 | return;
104 | }
105 |
106 | $data = json_decode( $response->body, true, 512, JSON_THROW_ON_ERROR );
107 |
108 | $this->logger->debug( 'Received response for sent message: ' . json_encode( $data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) );
109 |
110 | $json_rpc_response = $this->instantiateJsonRpcMessage( $data );
111 |
112 | parent::send( $json_rpc_response );
113 | }
114 |
115 | /**
116 | * Instantiate a JsonRpcMessage from decoded data.
117 | *
118 | * @param array $data The decoded JSON data.
119 | *
120 | * @return JsonRpcMessage The instantiated JsonRpcMessage object.
121 | *
122 | * @throws InvalidArgumentException If the message structure is invalid.
123 | */
124 | private function instantiateJsonRpcMessage( array $data ): JsonRpcMessage {
125 | if ( ! isset( $data['jsonrpc'] ) || '2.0' !== $data['jsonrpc'] ) {
126 | throw new InvalidArgumentException( 'Invalid JSON-RPC version.' );
127 | }
128 |
129 | if ( isset( $data['method'] ) ) {
130 | // It's a Request or Notification
131 | if ( isset( $data['id'] ) ) {
132 | // It's a Request
133 | return new JsonRpcMessage(
134 | new JSONRPCRequest(
135 | '2.0',
136 | new RequestId( $data['id'] ),
137 | $data['params'] ?? null,
138 | $data['method']
139 | )
140 | );
141 | }
142 |
143 | // It's a Notification
144 | return new JsonRpcMessage(
145 | new JSONRPCNotification(
146 | '2.0',
147 | $data['params'] ?? null,
148 | $data['method']
149 | )
150 | );
151 | }
152 |
153 | if ( isset( $data['result'] ) || isset( $data['error'] ) ) {
154 | // It's a Response or Error
155 | if ( isset( $data['error'] ) ) {
156 | // It's an Error
157 | $error_data = $data['error'];
158 | return new JsonRpcMessage(
159 | new JSONRPCError(
160 | '2.0',
161 | new RequestId( $data['id'] ?? 0 ),
162 | new JsonRpcErrorObject(
163 | $error_data['code'],
164 | $error_data['message'],
165 | $error_data['data'] ?? null
166 | )
167 | )
168 | );
169 | }
170 |
171 | // It's a Response
172 | return new JsonRpcMessage(
173 | new JSONRPCResponse(
174 | '2.0',
175 | new RequestId( $data['id'] ?? 0 ),
176 | $data['result']
177 | )
178 | );
179 | }
180 |
181 | throw new InvalidArgumentException( 'Invalid JSON-RPC message structure.' );
182 | }
183 | };
184 |
185 | return [ $shared_stream, $shared_stream ];
186 | }
187 | }
188 |
--------------------------------------------------------------------------------
/src/MCP/InMemorySession.php:
--------------------------------------------------------------------------------
1 | logger = $logger ?? new NullLogger();
46 | $this->read_stream = $read_stream;
47 | $this->write_stream = $write_stream;
48 |
49 | parent::__construct(
50 | $read_stream,
51 | $write_stream,
52 | null,
53 | $this->logger
54 | );
55 | }
56 |
57 | /**
58 | * Sends a request and waits for a typed result. If an error response is received, throws an exception.
59 | *
60 | * @param McpModel $request A typed request object (e.g., InitializeRequest, PingRequest).
61 | * @param string $result_type The fully-qualified class name of the expected result type (must implement McpModel). TODO: Implement.
62 | * @return McpModel The validated result object.
63 | * @throws McpError If an error response is received.
64 | *
65 | * @phpstan-param Request $request
66 | */
67 | public function sendRequest( McpModel $request, string $result_type ): McpModel {
68 | $this->validate_request_object( $request );
69 |
70 | $request_id_value = $this->request_id++;
71 | $request_id = new RequestId( $request_id_value );
72 |
73 | // Convert the typed request into a JSON-RPC request message
74 | // Assuming $request has public properties: method, params
75 | $json_rpc_request = new JsonRpcMessage(
76 | new JSONRPCRequest(
77 | '2.0',
78 | $request_id,
79 | $request->params ?? null,
80 | $request->method
81 | )
82 | );
83 |
84 | // Send the request message
85 | $this->writeMessage( $json_rpc_request );
86 |
87 | $message = $this->readNextMessage();
88 |
89 | $inner_message = $message->message;
90 |
91 | if ( $inner_message instanceof JSONRPCError ) {
92 | // It's an error response
93 | // Convert JsonRpcErrorObject into ErrorData
94 | $error_data = new ErrorData(
95 | $inner_message->error->code,
96 | $inner_message->error->message,
97 | $inner_message->error->data
98 | );
99 | throw new McpError( $error_data );
100 | }
101 |
102 | if ( $inner_message instanceof JSONRPCResponse ) {
103 | // Coming from HttpTransport.
104 | if ( is_array( $inner_message->result ) ) {
105 | return $result_type::fromResponseData( $inner_message->result );
106 | }
107 |
108 | // InMemoryTransport already returns the correct instances.
109 | return $inner_message->result;
110 | }
111 |
112 | // Invalid response
113 | throw new InvalidArgumentException( 'Invalid JSON-RPC response received' );
114 | }
115 |
116 | private function validate_request_object( McpModel $request ): void {
117 | // Check if request has a method property
118 | if ( ! property_exists( $request, 'method' ) || empty( $request->method ) ) {
119 | throw new InvalidArgumentException( 'Request must have a method' );
120 | }
121 | }
122 |
123 | /**
124 | * Write a JsonRpcMessage to the write stream.
125 | *
126 | * @param JsonRpcMessage $message The JSON-RPC message to send.
127 | *
128 | * @throws RuntimeException If writing to the stream fails.
129 | *
130 | * @return void
131 | */
132 | protected function writeMessage( JsonRpcMessage $message ): void {
133 | $this->logger->debug( 'Sending message to server: ' . json_encode( $message, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) );
134 | $this->write_stream->send( $message );
135 | }
136 |
137 | /**
138 | * Read the next message from the read stream.
139 | *
140 | * @throws RuntimeException If an invalid message type is received.
141 | *
142 | * @return JsonRpcMessage The received JSON-RPC message.
143 | */
144 | protected function readNextMessage(): JsonRpcMessage {
145 | return $this->read_stream->receive();
146 | }
147 |
148 | /**
149 | * Start any additional message processing mechanisms if necessary.
150 | *
151 | * @return void
152 | */
153 | protected function startMessageProcessing(): void {
154 | // Not used.
155 | }
156 |
157 | /**
158 | * Stop any additional message processing mechanisms if necessary.
159 | *
160 | * @return void
161 | */
162 | protected function stopMessageProcessing(): void {
163 | // Not used.
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/src/MCP/InMemoryTransport.php:
--------------------------------------------------------------------------------
1 | server,$this->logger) extends MemoryStream {
23 | private LoggerInterface $logger;
24 |
25 | public function __construct( private readonly Server $server, LoggerInterface $logger ) {
26 | $this->logger = $logger;
27 | }
28 |
29 | /**
30 | * Send a JsonRpcMessage or Exception to the server via SSE.
31 | *
32 | * @param JsonRpcMessage|Exception $message The JSON-RPC message or exception to send.
33 | *
34 | * @return void
35 | *
36 | * @throws InvalidArgumentException If the message is not a JsonRpcMessage.
37 | * @throws RuntimeException If sending the message fails.
38 | */
39 | public function send( mixed $message ): void {
40 | if ( ! $message instanceof JsonRpcMessage ) {
41 | throw new InvalidArgumentException( 'Only JsonRpcMessage instances can be sent.' );
42 | }
43 |
44 | $response = $this->server->handle_message( $message );
45 |
46 | $this->logger->debug( 'Received response for sent message: ' . json_encode( $response, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) );
47 |
48 | if ( null !== $response ) {
49 | parent::send( $response );
50 | }
51 | }
52 | };
53 |
54 | return [ $shared_stream, $shared_stream ];
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/MCP/Servers/WP_CLI/Tools/CliCommands.php:
--------------------------------------------------------------------------------
1 |
21 | */
22 | private function get_commands( WP_CLI\Dispatcher\CompositeCommand $command ): array {
23 | if ( WP_CLI::get_runner()->is_command_disabled( $command ) ) {
24 | return [];
25 | }
26 |
27 | // Value is different if it's a RootCommand instance.
28 | // @phpstan-ignore booleanNot.alwaysFalse
29 | if ( ! $command->can_have_subcommands() ) {
30 | return [ $command ];
31 | }
32 |
33 | $commands = [];
34 |
35 | /**
36 | * @var WP_CLI\Dispatcher\CompositeCommand $subcommand
37 | */
38 | foreach ( $command->get_subcommands() as $subcommand ) {
39 | array_push( $commands, ...$this->get_commands( $subcommand ) );
40 | }
41 |
42 | return $commands;
43 | }
44 |
45 | /**
46 | * Returns a list of tools.
47 | *
48 | * @return array Tools.
49 | */
50 | public function get_tools(): array {
51 | $commands = $this->get_commands( WP_CLI::get_root_command() );
52 |
53 | $tools = [];
54 |
55 | /**
56 | * Command class.
57 | *
58 | * @var WP_CLI\Dispatcher\RootCommand|WP_CLI\Dispatcher\Subcommand $command
59 | */
60 | foreach ( $commands as $command ) {
61 | $command_name = implode( ' ', get_path( $command ) );
62 |
63 | $command_desc = $command->get_shortdesc();
64 | $command_synopsis = $command->get_synopsis();
65 |
66 | /**
67 | * Parsed synopsys.
68 | *
69 | * @var array $synopsis_spec
70 | */
71 | $synopsis_spec = SynopsisParser::parse( $command_synopsis );
72 |
73 | $properties = [];
74 | $required = [];
75 |
76 | $this->logger->debug( "Synopsis for command: \"$command_name\"" . ' - ' . print_r( $command_synopsis, true ) );
77 |
78 | foreach ( $synopsis_spec as $arg ) {
79 | if ( 'positional' === $arg['type'] || 'assoc' === $arg['type'] ) {
80 | $prop_name = str_replace( [ '-', '|' ], '_', $arg['name'] );
81 | $properties[ $prop_name ] = [
82 | 'type' => 'string',
83 | 'description' => "Parameter {$arg['name']}",
84 | ];
85 |
86 | if ( ! isset( $arg['optional'] ) || ! $arg['optional'] ) {
87 | $required[] = $prop_name;
88 | }
89 | }
90 | }
91 |
92 | if ( empty( $properties ) ) {
93 | // Some commands such as "wp cache flush" don't take any parameters,
94 | // but the MCP SDK doesn't seem to like empty $properties.
95 | $properties['dummy'] = [
96 | 'type' => 'string',
97 | 'description' => 'Dummy parameter',
98 | ];
99 | }
100 |
101 | $tool = [
102 | 'name' => 'wp_cli_' . str_replace( ' ', '_', $command_name ),
103 | 'description' => $command_desc,
104 | 'inputSchema' => [
105 | 'type' => 'object',
106 | 'properties' => $properties,
107 | 'required' => $required,
108 | ],
109 | 'callback' => function ( $params ) use ( $command_name, $synopsis_spec ) {
110 | $args = [];
111 | $assoc_args = [];
112 |
113 | // Process positional arguments first
114 | foreach ( $synopsis_spec as $arg ) {
115 | if ( 'positional' === $arg['type'] ) {
116 | $prop_name = str_replace( '-', '_', $arg['name'] );
117 | if ( isset( $params[ $prop_name ] ) ) {
118 | $args[] = $params[ $prop_name ];
119 | }
120 | }
121 | }
122 |
123 | // Process associative arguments and flags
124 | foreach ( $params as $key => $value ) {
125 | // Skip positional args and dummy param
126 | if ( 'dummy' === $key ) {
127 | continue;
128 | }
129 |
130 | // Check if this is an associative argument
131 | foreach ( $synopsis_spec as $arg ) {
132 | if ( ( 'assoc' === $arg['type'] || 'flag' === $arg['type'] ) &&
133 | str_replace( '-', '_', $arg['name'] ) === $key ) {
134 | $assoc_args[ str_replace( '_', '-', $key ) ] = $value;
135 | break;
136 | }
137 | }
138 | }
139 |
140 | ob_start();
141 | WP_CLI::run_command( array_merge( explode( ' ', $command_name ), $args ), $assoc_args );
142 | return ob_get_clean();
143 | },
144 | ];
145 |
146 | $tools[] = $tool;
147 | }
148 |
149 | return $tools;
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/src/MCP/Servers/WP_CLI/WP_CLI.php:
--------------------------------------------------------------------------------
1 | logger ) )->get_tools(),
14 | ];
15 |
16 | foreach ( $all_tools as $tool ) {
17 | $this->register_tool( $tool );
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/McpServerCommand.php:
--------------------------------------------------------------------------------
1 | =]
23 | * : Filter results by key=value pairs.
24 | *
25 | * [--format=]
26 | * : Render output in a particular format.
27 | * ---
28 | * default: table
29 | * options:
30 | * - table
31 | * - csv
32 | * - json
33 | * - count
34 | * ---
35 | *
36 | * ## EXAMPLES
37 | *
38 | * # Greet the world.
39 | * $ wp mcp server list
40 | * Success: Hello World!
41 | *
42 | * # Greet the world.
43 | * $ wp ai "create 10 test posts about swiss recipes and include generated featured images"
44 | * Success: Hello World!
45 | *
46 | * @subcommand list
47 | *
48 | * @when before_wp_load
49 | *
50 | * @param string[] $args Indexed array of positional arguments.
51 | * @param array $assoc_args Associative arguments.
52 | */
53 | public function list_( $args, $assoc_args ): void {
54 | $_servers = $this->get_config()->get_servers();
55 |
56 | $servers = [];
57 |
58 | foreach ( $_servers as $server ) {
59 | // Support features like --status=active.
60 | foreach ( array_keys( $server ) as $field ) {
61 | if ( isset( $assoc_args[ $field ] ) && ! in_array( $server[ $field ], array_map( 'trim', explode( ',', $assoc_args[ $field ] ) ), true ) ) {
62 | continue 2;
63 | }
64 | }
65 |
66 | $servers[] = $server;
67 | }
68 |
69 | $formatter = $this->get_formatter( $assoc_args );
70 | $formatter->display_items( $servers );
71 | }
72 |
73 | /**
74 | * Add a new MCP server to the list
75 | *
76 | * ## OPTIONS
77 | *
78 | *
79 | * : Name for referencing the server later
80 | *
81 | *
82 | * : Server command or URL.
83 | *
84 | * ## EXAMPLES
85 | *
86 | * # Add server from URL.
87 | * $ wp mcp server add "server-github" "https://github.com/mcp"
88 | * Success: Server added.
89 | *
90 | * # Add server with command to execute
91 | * $ wp mcp server add "server-filesystem" "npx -y @modelcontextprotocol/server-filesystem /my/allowed/folder/"
92 | * Success: Server added.
93 | *
94 | * @when before_wp_load
95 | *
96 | * @param string[] $args Indexed array of positional arguments.
97 | */
98 | public function add( $args ): void {
99 | if ( $this->get_config()->has_server( $args[0] ) ) {
100 | WP_CLI::error( 'Server already exists.' );
101 | } else {
102 | $this->get_config()->add_server(
103 | [
104 | 'name' => $args[0],
105 | 'server' => $args[1],
106 | 'status' => 'active',
107 | ]
108 | );
109 |
110 | WP_CLI::success( 'Server added.' );
111 | }
112 | }
113 |
114 | /**
115 | * Remove one or more MCP servers.
116 | *
117 | * ## OPTIONS
118 | *
119 | * [...]
120 | * : One or more servers to remove
121 | *
122 | * [--all]
123 | * : Whether to remove all servers.
124 | *
125 | * ## EXAMPLES
126 | *
127 | * # Remove server.
128 | * $ wp mcp server remove "server-filesystem"
129 | * Success: Server removed.
130 | *
131 | * @when before_wp_load
132 | *
133 | * @param string[] $args Indexed array of positional arguments.
134 | * @param array $assoc_args Associative arguments.
135 | */
136 | public function remove( $args, $assoc_args ): void {
137 | $all = (bool) Utils\get_flag_value( $assoc_args, 'all', false );
138 |
139 | if ( ! $all && empty( $args ) ) {
140 | WP_CLI::error( 'Please specify one or more servers, or use --all.' );
141 | }
142 |
143 | $successes = 0;
144 | $errors = 0;
145 | $count = count( $args );
146 |
147 | foreach ( $args as $server ) {
148 | if ( ! $this->get_config()->has_server( $server ) ) {
149 | WP_CLI::warning( "Server '$server' not found." );
150 | ++$errors;
151 | } else {
152 | $this->get_config()->remove_server( $server );
153 | ++$successes;
154 | }
155 | }
156 |
157 | Utils\report_batch_operation_results( 'server', 'remove', $count, $successes, $errors );
158 | }
159 |
160 | /**
161 | * Update an MCP server.
162 | *
163 | * ## OPTIONS
164 | *
165 | *
166 | * : Name of the server.
167 | *
168 | * --=
169 | * : One or more fields to update.
170 | *
171 | * ## EXAMPLES
172 | *
173 | * # Remove server.
174 | * $ wp mcp server update "server-filesystem" --status=inactive
175 | * Success: Server updated.
176 | *
177 | * @when before_wp_load
178 | *
179 | * @param string[] $args Indexed array of positional arguments.
180 | * @param array $assoc_args Associative arguments.
181 | */
182 | public function update( $args, array $assoc_args ): void {
183 | $server = $this->get_config()->get_server( $args[0] );
184 |
185 | if ( null === $server ) {
186 | WP_CLI::error( "Server '$args[0]' not found." );
187 | return;
188 | }
189 |
190 | foreach ( $server as $key => $value ) {
191 | if ( isset( $assoc_args[ $key ] ) ) {
192 | $new_value = $assoc_args[ $key ];
193 | if ( 'status' === $key ) {
194 | $new_value = 'inactive' === $new_value ? 'inactive' : 'active';
195 | }
196 | $server[ $key ] = $new_value;
197 | }
198 | }
199 |
200 | $this->get_config()->update_server( $args[0], $server );
201 |
202 | WP_CLI::success( 'Server updated.' );
203 | }
204 |
205 | /**
206 | * Returns a Formatter object based on supplied parameters.
207 | *
208 | * @param array $assoc_args Parameters passed to command. Determines formatting.
209 | * @return Formatter
210 | * @param-out array $assoc_args
211 | */
212 | protected function get_formatter( array &$assoc_args ) {
213 | return new Formatter(
214 | // TODO: Fix type.
215 | // @phpstan-ignore paramOut.type
216 | $assoc_args,
217 | [
218 | 'name',
219 | 'server',
220 | 'status',
221 | ]
222 | );
223 | }
224 |
225 | /**
226 | * Returns an McpConfig instance.
227 | *
228 | * @return McpConfig Config instance.
229 | */
230 | protected function get_config(): McpConfig {
231 | return new McpConfig();
232 | }
233 | }
234 |
--------------------------------------------------------------------------------
/src/Utils/CliLogger.php:
--------------------------------------------------------------------------------
1 | get_config()['servers'];
22 | }
23 |
24 | /**
25 | * Returns a server with the given name.
26 | *
27 | * @param string $name Server name.
28 | * @return McpConfigServer|null Server if found, null otherwise.
29 | */
30 | public function get_server( string $name ): ?array {
31 | $config = $this->get_config();
32 | foreach ( $config['servers'] as $server ) {
33 | if ( $name === $server['name'] ) {
34 | return $server;
35 | }
36 | }
37 |
38 | return null;
39 | }
40 |
41 | /**
42 | * Determines whether a server with the given name exists in the config.
43 | *
44 | * @param string $name Server name.
45 | * @return bool Whether the server exists.
46 | */
47 | public function has_server( string $name ): bool {
48 | return $this->get_server( $name ) !== null;
49 | }
50 |
51 | /**
52 | * Adds a new server to the list.
53 | *
54 | * @param McpConfigServer $server Server data.
55 | * @return void
56 | */
57 | public function add_server( array $server ): void {
58 | $config = $this->get_config();
59 | $config['servers'][] = $server;
60 | $this->update_config( $config );
61 | }
62 |
63 | /**
64 | * Updates a specific server in the config.
65 | * @param string $name Server name.
66 | * @param McpConfigServer $server Server data.
67 | * @return void
68 | */
69 | public function update_server( string $name, array $server ): void {
70 | $config = $this->get_config();
71 | foreach ( $config['servers'] as &$_server ) {
72 | if ( $name === $_server['name'] ) {
73 | $_server = $server;
74 | }
75 | }
76 |
77 | unset( $_server );
78 |
79 | $this->update_config( $config );
80 | }
81 |
82 | /**
83 | * Removes a given server from the config.
84 | *
85 | * @param string $name Server name.
86 | * @return void
87 | */
88 | public function remove_server( string $name ): void {
89 | $config = $this->get_config();
90 |
91 | foreach ( $config['servers'] as $key => $server ) {
92 | if ( $name === $server['name'] ) {
93 | unset( $config['servers'][ $key ] );
94 | }
95 | }
96 |
97 | $this->update_config( $config );
98 | }
99 |
100 | /**
101 | * Returns the current MCP config.
102 | *
103 | * @return array Config data.
104 | * @phpstan-return McpConfigData
105 | */
106 | protected function get_config() {
107 | $config_file = Utils\get_home_dir() . '/.wp-cli/ai-command.json';
108 |
109 | if ( ! file_exists( $config_file ) ) {
110 | return [
111 | 'servers' => [],
112 | ];
113 | }
114 |
115 | $json_content = file_get_contents( $config_file );
116 |
117 | if ( false === $json_content ) {
118 | return [
119 | 'servers' => [],
120 | ];
121 | }
122 |
123 | $config = json_decode( $json_content, true, 512, JSON_THROW_ON_ERROR );
124 |
125 | if ( null === $config ) {
126 | return [
127 | 'servers' => [],
128 | ];
129 | }
130 |
131 | /**
132 | * Loaded config.
133 | *
134 | * @var McpConfigData $config
135 | */
136 | return $config;
137 | }
138 |
139 | /**
140 | * Updates the MCP config.
141 | *
142 | * @param array $new_config Updated config.
143 | * @return bool Whether updating was successful.
144 | * @phpstan-param McpConfigData $new_config
145 | */
146 | protected function update_config( $new_config ): bool {
147 | $config_file = Utils\get_home_dir() . '/.wp-cli/ai-command.json';
148 |
149 | if ( ! file_exists( $config_file ) ) {
150 | touch( $config_file );
151 | }
152 |
153 | return (bool) file_put_contents( $config_file, json_encode( $new_config, JSON_PRETTY_PRINT ) );
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/tests/phpstan/bootstrap.php:
--------------------------------------------------------------------------------
1 | assertTrue( true );
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/wp-cli.yml:
--------------------------------------------------------------------------------
1 | require:
2 | - ai-command.php
3 |
--------------------------------------------------------------------------------