Compare commits
272 Commits
0.8.1-hotf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 306c2ea2e5 | |||
| 633708d0b5 | |||
| 9a307f5d16 | |||
| 45641b141d | |||
| 0ed0e39c35 | |||
| 7f0cd8274e | |||
| a2ddd7c5d4 | |||
| 9f9402f343 | |||
| d3c0217681 | |||
| c843005928 | |||
| b2258a1e83 | |||
| 3b754fb08e | |||
|
|
05e41d2ccc | ||
|
|
1e48310bcb | ||
|
|
d40140c2e8 | ||
|
|
faf3efc19b | ||
|
|
e3076ae05c | ||
|
|
5e8e78c31d | ||
|
|
8ca5a34d12 | ||
|
|
283371c313 | ||
|
|
5b54ce1281 | ||
|
|
ad39ec27e7 | ||
|
|
f2003b30c6 | ||
|
|
0014ca5e12 | ||
|
|
8a8deb6d39 | ||
|
|
8597100d54 | ||
|
|
89d721e56d | ||
|
|
0c15c54b99 | ||
|
|
a60764e649 | ||
|
|
eb3737d110 | ||
|
|
1067f4d22f | ||
|
|
24e8861a88 | ||
|
|
b09d0e1eb6 | ||
|
|
2731b5f7fa | ||
|
|
7083c7c9f2 | ||
|
|
6223839251 | ||
|
|
f5739971bb | ||
|
|
e33a6b806d | ||
|
|
fe9ab40994 | ||
|
|
8373795804 | ||
|
|
3473c8156d | ||
|
|
2915cccd85 | ||
|
|
3c5c38462b | ||
|
|
db3f7a3542 | ||
|
|
f7b936e102 | ||
|
|
3e41520688 | ||
|
|
2b963ce1ce | ||
|
|
af27fa2b8e | ||
|
|
549a4ec65a | ||
|
|
fef37483ba | ||
|
|
bf402e5eb4 | ||
|
|
a57e707a0b | ||
|
|
be7fa9baf9 | ||
|
|
73a8e2dee6 | ||
|
|
f05c27f6ab | ||
|
|
2609996ce6 | ||
|
|
b00b271d65 | ||
|
|
7a1fb0cfd9 | ||
|
|
63cb034943 | ||
|
|
60ffece84b | ||
|
|
42e6f0afe1 | ||
|
|
04e24916b1 | ||
|
|
c9d2f0761e | ||
|
|
f19b382e72 | ||
|
|
e8e2dd0618 | ||
|
|
9998298dfe | ||
|
|
5393f2e04c | ||
|
|
f9cbfbe822 | ||
|
|
b7ec3829d4 | ||
|
|
8b38d6b18f | ||
|
|
ae87292d7c | ||
|
|
163356f855 | ||
|
|
3b090d02e4 | ||
|
|
358f276b9b | ||
|
|
70016bf3b6 | ||
|
|
4d98b4a8bd | ||
|
|
5ad2c3c146 | ||
|
|
dd6f5287ca | ||
|
|
59cc89dbe9 | ||
|
|
79a09f8316 | ||
|
|
cfe82c81a1 | ||
|
|
82b3257028 | ||
|
|
45d135a426 | ||
|
|
fa458702ab | ||
|
|
173a45a200 | ||
|
|
745991b8ef | ||
|
|
42b0a77505 | ||
|
|
b3fc199d25 | ||
|
|
cdab12a8e5 | ||
|
|
966f95b5af | ||
|
|
2e4a52250a | ||
|
|
102fce5570 | ||
|
|
d1bff49deb | ||
|
|
5d0c3c48f6 | ||
|
|
7b9cd1e74d | ||
|
|
bf1026b6ec | ||
|
|
498e49e2d8 | ||
|
|
1dd99d651e | ||
|
|
1ec4e3b556 | ||
|
|
29b465fe45 | ||
|
|
6e4dcd6120 | ||
|
|
3a2e248527 | ||
|
|
e5f6524eb6 | ||
|
|
c843ac6e49 | ||
|
|
6ab7ac3e1a | ||
|
|
fa6fc62ecb | ||
|
|
b9c58252a1 | ||
|
|
114df8ac30 | ||
|
|
8213402185 | ||
|
|
36178632fc | ||
|
|
d82e06fe65 | ||
|
|
0e5a232967 | ||
|
|
bbe4924c88 | ||
|
|
d90940ab8f | ||
|
|
8ed2651ffd | ||
|
|
0f33c87ed4 | ||
|
|
2720d68417 | ||
|
|
b3971259d2 | ||
|
|
15fb99b644 | ||
|
|
958c9a36f1 | ||
|
|
92bf9abb40 | ||
|
|
bb7834543b | ||
|
|
d7dff30cdb | ||
|
|
2fb2f0fbaf | ||
|
|
214e5e4c25 | ||
|
|
d45d8c9125 | ||
|
|
7cad9a018c | ||
|
|
7c8f2926a0 | ||
|
|
3aa8753f7c | ||
|
|
014acecf77 | ||
|
|
d2dc959307 | ||
|
|
eba153847e | ||
|
|
1345a50011 | ||
|
|
15960f5aa4 | ||
|
|
0dc6df57d6 | ||
|
|
c8e9a772e4 | ||
|
|
cd28370f2c | ||
|
|
4adf4b1e51 | ||
|
|
880cb4a1de | ||
|
|
78cd701dc9 | ||
|
|
e31ee74842 | ||
|
|
9996be166f | ||
|
|
0b1c00baab | ||
|
|
8b0daef4ce | ||
|
|
49dddfb12b | ||
|
|
e878538d05 | ||
|
|
8901beb73e | ||
|
|
c65b5f8132 | ||
|
|
befa209935 | ||
|
|
8e8a5bb866 | ||
|
|
33309f351c | ||
|
|
d9100c8ed4 | ||
|
|
e4b4039e90 | ||
|
|
e798504867 | ||
|
|
6b57f7ce11 | ||
|
|
a4e6daae15 | ||
|
|
61614052e5 | ||
|
|
5696c19df9 | ||
|
|
fa7eba23b7 | ||
|
|
df0b526a41 | ||
|
|
0229aed948 | ||
|
|
a16a94537a | ||
|
|
9854409ece | ||
|
|
b19c78bc0a | ||
|
|
6a99feaf33 | ||
|
|
f7272d8d8c | ||
|
|
fd7dbba7a8 | ||
|
|
5fa76a7e02 | ||
|
|
fe02efd017 | ||
|
|
24418bc2cd | ||
|
|
22eb81350a | ||
|
|
0b37722ad1 | ||
|
|
485e703043 | ||
|
|
6420605d30 | ||
|
|
25cc0f69fd | ||
|
|
f1d5824a25 | ||
|
|
d332352968 | ||
|
|
d0622d3eb5 | ||
|
|
33aad8a271 | ||
|
|
352f5af7f8 | ||
|
|
3784816374 | ||
|
|
de233c9b30 | ||
|
|
dcef9dd068 | ||
|
|
204a90b1d1 | ||
|
|
8f7680d72b | ||
|
|
e20c4fb4ff | ||
|
|
5bcc1fd77b | ||
|
|
52564dd7c5 | ||
|
|
d2cb2338a0 | ||
|
|
a1fa0df0e6 | ||
|
|
f461f29f18 | ||
|
|
87fad79e7d | ||
|
|
cd1bcfb15f | ||
|
|
e4bfc058b2 | ||
|
|
bbff5c5883 | ||
|
|
8423776873 | ||
|
|
645a256994 | ||
|
|
1175af1074 | ||
|
|
c4cc4144d9 | ||
|
|
80854d9183 | ||
|
|
98ea4a12d6 | ||
|
|
2e8cc56744 | ||
|
|
3e95d9d42a | ||
|
|
a010f10c26 | ||
|
|
70dc1b766e | ||
|
|
d798d1a132 | ||
|
|
eab6b42ac6 | ||
|
|
53152e3fb1 | ||
|
|
4a2ba7ce6e | ||
|
|
aeac3ac914 | ||
|
|
a98db777c5 | ||
|
|
f43a9140ef | ||
|
|
638603c0f3 | ||
|
|
4f6c30904a | ||
|
|
a3a1ad47fa | ||
|
|
1596973b05 | ||
|
|
6c07559573 | ||
|
|
727e0753e4 | ||
|
|
aad3262940 | ||
|
|
7bc1088159 | ||
|
|
256972a366 | ||
|
|
a15df913fe | ||
|
|
068d43226f | ||
|
|
fe74dbea74 | ||
|
|
f6ed09e9fb | ||
|
|
4bc44c4d19 | ||
|
|
da4e2fa844 | ||
|
|
9c6663c4c2 | ||
|
|
cf20fe9142 | ||
|
|
6659d549ea | ||
|
|
48917ca4e5 | ||
|
|
779ca20568 | ||
|
|
f71613227f | ||
|
|
6a16e7cd24 | ||
|
|
3f9500f954 | ||
|
|
4053aa122d | ||
|
|
ad7865a6ab | ||
|
|
d69934afcb | ||
|
|
c5dc4ded5c | ||
|
|
4a3f032f59 | ||
|
|
6bcf1531c3 | ||
|
|
a506e4c419 | ||
|
|
a524f17d80 | ||
|
|
71bb89ddf2 | ||
|
|
ca1854b071 | ||
|
|
a3be8f6376 | ||
|
|
8d20044b33 | ||
|
|
eb9b2dd027 | ||
|
|
91633de80f | ||
|
|
6be05c5278 | ||
|
|
f36061e4b9 | ||
|
|
e35343537c | ||
|
|
402cde025b | ||
|
|
0fd2e12c7b | ||
|
|
b8ab30fdf3 | ||
|
|
06f237b1de | ||
|
|
a8ffc704f0 | ||
|
|
73d4cecdb1 | ||
|
|
952e13edc1 | ||
|
|
6eb6b3f82e | ||
|
|
3fc63017df | ||
|
|
cf86040df9 | ||
|
|
86a1adefd9 | ||
|
|
1832be2f84 | ||
|
|
1dfe10bf42 | ||
|
|
65c589c193 | ||
|
|
5b22143c85 | ||
|
|
a63de622f8 | ||
|
|
dae389034a | ||
|
|
f4e6f4d4ea | ||
|
|
9f42629b34 | ||
|
|
4c4c8a0884 |
60
.github/workflows/main.yml
vendored
60
.github/workflows/main.yml
vendored
@@ -1,60 +0,0 @@
|
|||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
pull_request:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: npx prettier --check .
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 18
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
# Working around https://github.com/npm/cli/issues/4828
|
|
||||||
# - run: npm ci
|
|
||||||
- run: npm install --no-package-lock
|
|
||||||
|
|
||||||
- name: Run client tests
|
|
||||||
working-directory: ./client
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- run: npm run build
|
|
||||||
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name == 'release'
|
|
||||||
environment: release
|
|
||||||
needs: build
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 18
|
|
||||||
cache: npm
|
|
||||||
registry-url: "https://registry.npmjs.org"
|
|
||||||
|
|
||||||
# Working around https://github.com/npm/cli/issues/4828
|
|
||||||
# - run: npm ci
|
|
||||||
- run: npm install --no-package-lock
|
|
||||||
|
|
||||||
# TODO: Add --provenance once the repo is public
|
|
||||||
- run: npm run publish-all
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,7 +1,11 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules
|
.vscode
|
||||||
|
.idea
|
||||||
|
node_modules/
|
||||||
|
*-workspace/
|
||||||
server/build
|
server/build
|
||||||
client/dist
|
client/dist
|
||||||
client/tsconfig.app.tsbuildinfo
|
client/tsconfig.app.tsbuildinfo
|
||||||
client/tsconfig.node.tsbuildinfo
|
client/tsconfig.node.tsbuildinfo
|
||||||
.vscode
|
cli/build
|
||||||
|
test-output
|
||||||
|
|||||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
|
||||||
|
RUN cd client && \
|
||||||
|
npm install && \
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
|
||||||
|
RUN cd server && \
|
||||||
|
npm install && \
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
RUN cd cli && \
|
||||||
|
npm install && \
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/client/bin ./client/bin
|
||||||
|
COPY --from=builder /app/client/dist ./client/dist
|
||||||
|
COPY --from=builder /app/server/build ./server/build
|
||||||
|
COPY --from=builder /app/cli/build ./cli/build
|
||||||
|
COPY --from=builder /app/package*.json ./
|
||||||
|
COPY --from=builder /app/sample-config.json ./
|
||||||
|
|
||||||
|
RUN npm ci --omit=dev --ignore-scripts
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
CMD ["node", "client/bin/start.js"]
|
||||||
204
README.md
204
README.md
@@ -1,11 +1,21 @@
|
|||||||
|
# Original Repo: [https://github.com/modelcontextprotocol/inspector](https://github.com/modelcontextprotocol/inspector)
|
||||||
|
|
||||||
|
## Modification Log
|
||||||
|
- Containerize
|
||||||
|
- Support custom headers for sse transport
|
||||||
|
|
||||||
# MCP Inspector
|
# MCP Inspector
|
||||||
|
|
||||||
The MCP inspector is a developer tool for testing and debugging MCP servers.
|
The MCP inspector is a developer tool for testing and debugging MCP servers.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Running the Inspector
|
## Running the Inspector
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Node.js: ^22.7.5
|
||||||
|
|
||||||
### From an MCP server repository
|
### From an MCP server repository
|
||||||
|
|
||||||
To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`:
|
To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`:
|
||||||
@@ -18,16 +28,16 @@ You can pass both arguments and environment variables to your MCP server. Argume
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Pass arguments only
|
# Pass arguments only
|
||||||
npx @modelcontextprotocol/inspector build/index.js arg1 arg2
|
npx @modelcontextprotocol/inspector node build/index.js arg1 arg2
|
||||||
|
|
||||||
# Pass environment variables only
|
# Pass environment variables only
|
||||||
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js
|
npx @modelcontextprotocol/inspector -e key=value -e key2=$VALUE2 node build/index.js
|
||||||
|
|
||||||
# Pass both environment variables and arguments
|
# Pass both environment variables and arguments
|
||||||
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js arg1 arg2
|
npx @modelcontextprotocol/inspector -e key=value -e key2=$VALUE2 node build/index.js arg1 arg2
|
||||||
|
|
||||||
# Use -- to separate inspector flags from server arguments
|
# Use -- to separate inspector flags from server arguments
|
||||||
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag
|
npx @modelcontextprotocol/inspector -e key=$VALUE -- node build/index.js -e server-flag
|
||||||
```
|
```
|
||||||
|
|
||||||
The inspector runs both an MCP Inspector (MCPI) client UI (default port 6274) and an MCP Proxy (MCPP) server (default port 6277). Open the MCPI client UI in your browser to use the inspector. (These ports are derived from the T9 dialpad mapping of MCPI and MCPP respectively, as a mnemonic). You can customize the ports if needed:
|
The inspector runs both an MCP Inspector (MCPI) client UI (default port 6274) and an MCP Proxy (MCPP) server (default port 6277). Open the MCPI client UI in your browser to use the inspector. (These ports are derived from the T9 dialpad mapping of MCPI and MCPP respectively, as a mnemonic). You can customize the ports if needed:
|
||||||
@@ -38,9 +48,77 @@ CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build
|
|||||||
|
|
||||||
For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging).
|
For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging).
|
||||||
|
|
||||||
|
### Servers File Export
|
||||||
|
|
||||||
|
The MCP Inspector provides convenient buttons to export server launch configurations for use in clients such as Cursor, Claude Code, or the Inspector's CLI. The file is usually called `mcp.json`.
|
||||||
|
|
||||||
|
- **Server Entry** - Copies a single server configuration entry to your clipboard. This can be added to your `mcp.json` file inside the `mcpServers` object with your preferred server name.
|
||||||
|
|
||||||
|
**STDIO transport example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "node",
|
||||||
|
"args": ["build/index.js", "--debug"],
|
||||||
|
"env": {
|
||||||
|
"API_KEY": "your-api-key",
|
||||||
|
"DEBUG": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**SSE transport example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://localhost:3000/events",
|
||||||
|
"note": "For SSE connections, add this URL directly in Client"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Servers File** - Copies a complete MCP configuration file structure to your clipboard, with your current server configuration added as `default-server`. This can be saved directly as `mcp.json`.
|
||||||
|
|
||||||
|
**STDIO transport example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"default-server": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["build/index.js", "--debug"],
|
||||||
|
"env": {
|
||||||
|
"API_KEY": "your-api-key",
|
||||||
|
"DEBUG": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**SSE transport example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"default-server": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://localhost:3000/events",
|
||||||
|
"note": "For SSE connections, add this URL directly in Client"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These buttons appear in the Inspector UI after you've configured your server settings, making it easy to save and reuse your configurations.
|
||||||
|
|
||||||
|
For SSE transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the SSE URL configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the SSE URL for direct use in clients.
|
||||||
|
|
||||||
|
You can paste the Server Entry into your existing `mcp.json` file under your chosen server name, or use the complete Servers File payload to create a new configuration file.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header.
|
The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header. You can override the header name using the input field in the sidebar.
|
||||||
|
|
||||||
### Security Considerations
|
### Security Considerations
|
||||||
|
|
||||||
@@ -48,12 +126,65 @@ The MCP Inspector includes a proxy server that can run and communicate with loca
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
The MCP Inspector supports the following configuration settings. To change them click on the `Configuration` button in the MCP Inspector UI :
|
The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI:
|
||||||
|
|
||||||
| Name | Purpose | Default Value |
|
| Setting | Description | Default |
|
||||||
| -------------------------- | ----------------------------------------------------------------------------------------- | ------------- |
|
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
| MCP_SERVER_REQUEST_TIMEOUT | Maximum time in milliseconds to wait for a response from the MCP server before timing out | 10000 |
|
| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 |
|
||||||
| MCP_PROXY_FULL_ADDRESS | The full URL of the MCP Inspector proxy server (e.g. `http://10.2.1.14:2277`) | `null` |
|
| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true |
|
||||||
|
| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 |
|
||||||
|
| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" |
|
||||||
|
| `MCP_AUTO_OPEN_ENABLED` | Enable automatic browser opening when inspector starts. Only as environment var, not configurable in browser. | true |
|
||||||
|
|
||||||
|
These settings can be adjusted in real-time through the UI and will persist across sessions.
|
||||||
|
|
||||||
|
The inspector also supports configuration files to store settings for different MCP servers. This is useful when working with multiple servers or complex configurations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @modelcontextprotocol/inspector --config path/to/config.json --server everything
|
||||||
|
```
|
||||||
|
|
||||||
|
Example server configuration file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"everything": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["@modelcontextprotocol/server-everything"],
|
||||||
|
"env": {
|
||||||
|
"hello": "Hello MCP!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"my-server": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["build/index.js", "arg1", "arg2"],
|
||||||
|
"env": {
|
||||||
|
"key": "value",
|
||||||
|
"key2": "value2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tip:** You can easily generate this configuration format using the **Server Entry** and **Servers File** buttons in the Inspector UI, as described in the Servers File Export section above.
|
||||||
|
|
||||||
|
You can also set the initial `transport` type, `serverUrl`, `serverCommand`, and `serverArgs` via query params, for example:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:6274/?transport=sse&serverUrl=http://localhost:8787/sse
|
||||||
|
http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:8787/mcp
|
||||||
|
http://localhost:6274/?transport=stdio&serverCommand=npx&serverArgs=arg1%20arg2
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also set initial config settings via query params, for example:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:6274/?MCP_SERVER_REQUEST_TIMEOUT=10000&MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS=false&MCP_PROXY_FULL_ADDRESS=http://10.1.1.22:5577
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that if both the query param and the corresponding localStorage item are set, the query param will take precedence.
|
||||||
|
|
||||||
### From this repository
|
### From this repository
|
||||||
|
|
||||||
@@ -79,6 +210,57 @@ npm run build
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### CLI Mode
|
||||||
|
|
||||||
|
CLI mode enables programmatic interaction with MCP servers from the command line, ideal for scripting, automation, and integration with coding assistants. This creates an efficient feedback loop for MCP server development.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @modelcontextprotocol/inspector --cli node build/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI mode supports most operations across tools, resources, and prompts. A few examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic usage
|
||||||
|
npx @modelcontextprotocol/inspector --cli node build/index.js
|
||||||
|
|
||||||
|
# With config file
|
||||||
|
npx @modelcontextprotocol/inspector --cli --config path/to/config.json --server myserver
|
||||||
|
|
||||||
|
# List available tools
|
||||||
|
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list
|
||||||
|
|
||||||
|
# Call a specific tool
|
||||||
|
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg key=value --tool-arg another=value2
|
||||||
|
|
||||||
|
# List available resources
|
||||||
|
npx @modelcontextprotocol/inspector --cli node build/index.js --method resources/list
|
||||||
|
|
||||||
|
# List available prompts
|
||||||
|
npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/list
|
||||||
|
|
||||||
|
# Connect to a remote MCP server
|
||||||
|
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com
|
||||||
|
|
||||||
|
# Call a tool on a remote server
|
||||||
|
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method tools/call --tool-name remotetool --tool-arg param=value
|
||||||
|
|
||||||
|
# List resources from a remote server
|
||||||
|
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method resources/list
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI Mode vs CLI Mode: When to Use Each
|
||||||
|
|
||||||
|
| Use Case | UI Mode | CLI Mode |
|
||||||
|
| ------------------------ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **Server Development** | Visual interface for interactive testing and debugging during development | Scriptable commands for quick testing and continuous integration; creates feedback loops with AI coding assistants like Cursor for rapid development |
|
||||||
|
| **Resource Exploration** | Interactive browser with hierarchical navigation and JSON visualization | Programmatic listing and reading for automation and scripting |
|
||||||
|
| **Tool Testing** | Form-based parameter input with real-time response visualization | Command-line tool execution with JSON output for scripting |
|
||||||
|
| **Prompt Engineering** | Interactive sampling with streaming responses and visual comparison | Batch processing of prompts with machine-readable output |
|
||||||
|
| **Debugging** | Request history, visualized errors, and real-time notifications | Direct JSON output for log analysis and integration with other tools |
|
||||||
|
| **Automation** | N/A | Ideal for CI/CD pipelines, batch processing, and integration with coding assistants |
|
||||||
|
| **Learning MCP** | Rich visual interface helps new users understand server capabilities | Simplified commands for focused learning of specific endpoints |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License—see the [LICENSE](LICENSE) file for details.
|
This project is licensed under the MIT License—see the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
28
cli/package.json
Normal file
28
cli/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@modelcontextprotocol/inspector-cli",
|
||||||
|
"version": "0.12.0",
|
||||||
|
"description": "CLI for the Model Context Protocol inspector",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
"homepage": "https://modelcontextprotocol.io",
|
||||||
|
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
|
||||||
|
"main": "build/cli.js",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"mcp-inspector-cli": "build/cli.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"build"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"postbuild": "node scripts/make-executable.js",
|
||||||
|
"test": "node scripts/cli-tests.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
|
"commander": "^13.1.0",
|
||||||
|
"spawn-rx": "^5.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
633
cli/scripts/cli-tests.js
Executable file
633
cli/scripts/cli-tests.js
Executable file
@@ -0,0 +1,633 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Colors for output
|
||||||
|
const colors = {
|
||||||
|
GREEN: "\x1b[32m",
|
||||||
|
YELLOW: "\x1b[33m",
|
||||||
|
RED: "\x1b[31m",
|
||||||
|
BLUE: "\x1b[34m",
|
||||||
|
ORANGE: "\x1b[33m",
|
||||||
|
NC: "\x1b[0m", // No Color
|
||||||
|
};
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { execSync, spawn } from "child_process";
|
||||||
|
import os from "os";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
// Get directory paths with ESM compatibility
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Track test results
|
||||||
|
let PASSED_TESTS = 0;
|
||||||
|
let FAILED_TESTS = 0;
|
||||||
|
let SKIPPED_TESTS = 0;
|
||||||
|
let TOTAL_TESTS = 0;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${colors.YELLOW}=== MCP Inspector CLI Test Script ===${colors.NC}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`${colors.BLUE}This script tests the MCP Inspector CLI's ability to handle various command line options:${colors.NC}`,
|
||||||
|
);
|
||||||
|
console.log(`${colors.BLUE}- Basic CLI mode${colors.NC}`);
|
||||||
|
console.log(`${colors.BLUE}- Environment variables (-e)${colors.NC}`);
|
||||||
|
console.log(`${colors.BLUE}- Config file (--config)${colors.NC}`);
|
||||||
|
console.log(`${colors.BLUE}- Server selection (--server)${colors.NC}`);
|
||||||
|
console.log(`${colors.BLUE}- Method selection (--method)${colors.NC}`);
|
||||||
|
console.log(
|
||||||
|
`${colors.BLUE}- Tool-related options (--tool-name, --tool-arg)${colors.NC}`,
|
||||||
|
);
|
||||||
|
console.log(`${colors.BLUE}- Resource-related options (--uri)${colors.NC}`);
|
||||||
|
console.log(
|
||||||
|
`${colors.BLUE}- Prompt-related options (--prompt-name, --prompt-args)${colors.NC}`,
|
||||||
|
);
|
||||||
|
console.log(`${colors.BLUE}- Logging options (--log-level)${colors.NC}\n`);
|
||||||
|
|
||||||
|
// Get directory paths
|
||||||
|
const SCRIPTS_DIR = __dirname;
|
||||||
|
const PROJECT_ROOT = path.join(SCRIPTS_DIR, "../../");
|
||||||
|
const BUILD_DIR = path.resolve(SCRIPTS_DIR, "../build");
|
||||||
|
|
||||||
|
// Define the test server command using npx
|
||||||
|
const TEST_CMD = "npx";
|
||||||
|
const TEST_ARGS = ["@modelcontextprotocol/server-everything"];
|
||||||
|
|
||||||
|
// Create output directory for test results
|
||||||
|
const OUTPUT_DIR = path.join(SCRIPTS_DIR, "test-output");
|
||||||
|
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||||
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary directory for test files
|
||||||
|
const TEMP_DIR = fs.mkdirSync(path.join(os.tmpdir(), "mcp-inspector-tests"), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("exit", () => {
|
||||||
|
try {
|
||||||
|
fs.rmSync(TEMP_DIR, { recursive: true, force: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`${colors.RED}Failed to remove temp directory: ${err.message}${colors.NC}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use the existing sample config file
|
||||||
|
console.log(
|
||||||
|
`${colors.BLUE}Using existing sample config file: ${PROJECT_ROOT}/sample-config.json${colors.NC}`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const sampleConfig = fs.readFileSync(
|
||||||
|
path.join(PROJECT_ROOT, "sample-config.json"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
console.log(sampleConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`${colors.RED}Error reading sample config: ${error.message}${colors.NC}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an invalid config file for testing
|
||||||
|
const invalidConfigPath = path.join(TEMP_DIR, "invalid-config.json");
|
||||||
|
fs.writeFileSync(invalidConfigPath, '{\n "mcpServers": {\n "invalid": {');
|
||||||
|
|
||||||
|
// Function to run a basic test
|
||||||
|
async function runBasicTest(testName, ...args) {
|
||||||
|
const outputFile = path.join(
|
||||||
|
OUTPUT_DIR,
|
||||||
|
`${testName.replace(/\//g, "_")}.log`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n${colors.YELLOW}Testing: ${testName}${colors.NC}`);
|
||||||
|
TOTAL_TESTS++;
|
||||||
|
|
||||||
|
// Run the command and capture output
|
||||||
|
console.log(
|
||||||
|
`${colors.BLUE}Command: node ${BUILD_DIR}/cli.js ${args.join(" ")}${colors.NC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a write stream for the output file
|
||||||
|
const outputStream = fs.createWriteStream(outputFile);
|
||||||
|
|
||||||
|
// Spawn the process
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn("node", [path.join(BUILD_DIR, "cli.js"), ...args], {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pipe stdout and stderr to the output file
|
||||||
|
child.stdout.pipe(outputStream);
|
||||||
|
child.stderr.pipe(outputStream);
|
||||||
|
|
||||||
|
// Also capture output for display
|
||||||
|
let output = "";
|
||||||
|
child.stdout.on("data", (data) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (data) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
outputStream.end();
|
||||||
|
|
||||||
|
if (code === 0) {
|
||||||
|
console.log(`${colors.GREEN}✓ Test passed: ${testName}${colors.NC}`);
|
||||||
|
console.log(`${colors.BLUE}First few lines of output:${colors.NC}`);
|
||||||
|
const firstFewLines = output
|
||||||
|
.split("\n")
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((line) => ` ${line}`)
|
||||||
|
.join("\n");
|
||||||
|
console.log(firstFewLines);
|
||||||
|
PASSED_TESTS++;
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
console.log(`${colors.RED}✗ Test failed: ${testName}${colors.NC}`);
|
||||||
|
console.log(`${colors.RED}Error output:${colors.NC}`);
|
||||||
|
console.log(
|
||||||
|
output
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => ` ${line}`)
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
FAILED_TESTS++;
|
||||||
|
|
||||||
|
// Stop after any error is encountered
|
||||||
|
console.log(
|
||||||
|
`${colors.YELLOW}Stopping tests due to error. Please validate and fix before continuing.${colors.NC}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`${colors.RED}Error running test: ${error.message}${colors.NC}`,
|
||||||
|
);
|
||||||
|
FAILED_TESTS++;
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to run an error test (expected to fail)
|
||||||
|
async function runErrorTest(testName, ...args) {
|
||||||
|
const outputFile = path.join(
|
||||||
|
OUTPUT_DIR,
|
||||||
|
`${testName.replace(/\//g, "_")}.log`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n${colors.YELLOW}Testing error case: ${testName}${colors.NC}`);
|
||||||
|
TOTAL_TESTS++;
|
||||||
|
|
||||||
|
// Run the command and capture output
|
||||||
|
console.log(
|
||||||
|
`${colors.BLUE}Command: node ${BUILD_DIR}/cli.js ${args.join(" ")}${colors.NC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a write stream for the output file
|
||||||
|
const outputStream = fs.createWriteStream(outputFile);
|
||||||
|
|
||||||
|
// Spawn the process
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn("node", [path.join(BUILD_DIR, "cli.js"), ...args], {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pipe stdout and stderr to the output file
|
||||||
|
child.stdout.pipe(outputStream);
|
||||||
|
child.stderr.pipe(outputStream);
|
||||||
|
|
||||||
|
// Also capture output for display
|
||||||
|
let output = "";
|
||||||
|
child.stdout.on("data", (data) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (data) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
outputStream.end();
|
||||||
|
|
||||||
|
// For error tests, we expect a non-zero exit code
|
||||||
|
if (code !== 0) {
|
||||||
|
console.log(
|
||||||
|
`${colors.GREEN}✓ Error test passed: ${testName}${colors.NC}`,
|
||||||
|
);
|
||||||
|
console.log(`${colors.BLUE}Error output (expected):${colors.NC}`);
|
||||||
|
const firstFewLines = output
|
||||||
|
.split("\n")
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((line) => ` ${line}`)
|
||||||
|
.join("\n");
|
||||||
|
console.log(firstFewLines);
|
||||||
|
PASSED_TESTS++;
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`${colors.RED}✗ Error test failed: ${testName} (expected error but got success)${colors.NC}`,
|
||||||
|
);
|
||||||
|
console.log(`${colors.RED}Output:${colors.NC}`);
|
||||||
|
console.log(
|
||||||
|
output
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => ` ${line}`)
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
FAILED_TESTS++;
|
||||||
|
|
||||||
|
// Stop after any error is encountered
|
||||||
|
console.log(
|
||||||
|
`${colors.YELLOW}Stopping tests due to error. Please validate and fix before continuing.${colors.NC}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`${colors.RED}Error running test: ${error.message}${colors.NC}`,
|
||||||
|
);
|
||||||
|
FAILED_TESTS++;
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all tests
|
||||||
|
async function runTests() {
|
||||||
|
console.log(
|
||||||
|
`\n${colors.YELLOW}=== Running Basic CLI Mode Tests ===${colors.NC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 1: Basic CLI mode with method
|
||||||
|
await runBasicTest(
|
||||||
|
"basic_cli_mode",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 2: CLI mode with non-existent method (should fail)
|
||||||
|
await runErrorTest(
|
||||||
|
"nonexistent_method",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"nonexistent/method",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 3: CLI mode without method (should fail)
|
||||||
|
await runErrorTest("missing_method", TEST_CMD, ...TEST_ARGS, "--cli");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n${colors.YELLOW}=== Running Environment Variable Tests ===${colors.NC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 4: CLI mode with environment variables
|
||||||
|
await runBasicTest(
|
||||||
|
"env_variables",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"-e",
|
||||||
|
"KEY1=value1",
|
||||||
|
"-e",
|
||||||
|
"KEY2=value2",
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 5: CLI mode with invalid environment variable format (should fail)
|
||||||
|
await runErrorTest(
|
||||||
|
"invalid_env_format",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"-e",
|
||||||
|
"INVALID_FORMAT",
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 5b: CLI mode with environment variable containing equals sign in value
|
||||||
|
await runBasicTest(
|
||||||
|
"env_variable_with_equals",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"-e",
|
||||||
|
"API_KEY=abc123=xyz789==",
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 5c: CLI mode with environment variable containing base64-encoded value
|
||||||
|
await runBasicTest(
|
||||||
|
"env_variable_with_base64",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"-e",
|
||||||
|
"JWT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=",
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n${colors.YELLOW}=== Running Config File Tests ===${colors.NC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 6: Using config file with CLI mode
|
||||||
|
await runBasicTest(
|
||||||
|
"config_file",
|
||||||
|
"--config",
|
||||||
|
path.join(PROJECT_ROOT, "sample-config.json"),
|
||||||
|
"--server",
|
||||||
|
"everything",
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 7: Using config file without server name (should fail)
|
||||||
|
await runErrorTest(
|
||||||
|
"config_without_server",
|
||||||
|
"--config",
|
||||||
|
path.join(PROJECT_ROOT, "sample-config.json"),
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 8: Using server name without config file (should fail)
|
||||||
|
await runErrorTest(
|
||||||
|
"server_without_config",
|
||||||
|
"--server",
|
||||||
|
"everything",
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 9: Using non-existent config file (should fail)
|
||||||
|
await runErrorTest(
|
||||||
|
"nonexistent_config",
|
||||||
|
"--config",
|
||||||
|
"./nonexistent-config.json",
|
||||||
|
"--server",
|
||||||
|
"everything",
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 10: Using invalid config file format (should fail)
|
||||||
|
await runErrorTest(
|
||||||
|
"invalid_config",
|
||||||
|
"--config",
|
||||||
|
invalidConfigPath,
|
||||||
|
"--server",
|
||||||
|
"everything",
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 11: Using config file with non-existent server (should fail)
|
||||||
|
await runErrorTest(
|
||||||
|
"nonexistent_server",
|
||||||
|
"--config",
|
||||||
|
path.join(PROJECT_ROOT, "sample-config.json"),
|
||||||
|
"--server",
|
||||||
|
"nonexistent",
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n${colors.YELLOW}=== Running Tool-Related Tests ===${colors.NC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 12: CLI mode with tool call
|
||||||
|
await runBasicTest(
|
||||||
|
"tool_call",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/call",
|
||||||
|
"--tool-name",
|
||||||
|
"echo",
|
||||||
|
"--tool-arg",
|
||||||
|
"message=Hello",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 13: CLI mode with tool call but missing tool name (should fail)
|
||||||
|
await runErrorTest(
|
||||||
|
"missing_tool_name",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/call",
|
||||||
|
"--tool-arg",
|
||||||
|
"message=Hello",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 14: CLI mode with tool call but invalid tool args format (should fail)
|
||||||
|
await runErrorTest(
|
||||||
|
"invalid_tool_args",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/call",
|
||||||
|
"--tool-name",
|
||||||
|
"echo",
|
||||||
|
"--tool-arg",
|
||||||
|
"invalid_format",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 15: CLI mode with multiple tool args
|
||||||
|
await runBasicTest(
|
||||||
|
"multiple_tool_args",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/call",
|
||||||
|
"--tool-name",
|
||||||
|
"add",
|
||||||
|
"--tool-arg",
|
||||||
|
"a=1",
|
||||||
|
"b=2",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n${colors.YELLOW}=== Running Resource-Related Tests ===${colors.NC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 16: CLI mode with resource read
|
||||||
|
await runBasicTest(
|
||||||
|
"resource_read",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"resources/read",
|
||||||
|
"--uri",
|
||||||
|
"test://static/resource/1",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 17: CLI mode with resource read but missing URI (should fail)
|
||||||
|
await runErrorTest(
|
||||||
|
"missing_uri",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"resources/read",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n${colors.YELLOW}=== Running Prompt-Related Tests ===${colors.NC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 18: CLI mode with prompt get
|
||||||
|
await runBasicTest(
|
||||||
|
"prompt_get",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"prompts/get",
|
||||||
|
"--prompt-name",
|
||||||
|
"simple_prompt",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 19: CLI mode with prompt get and args
|
||||||
|
await runBasicTest(
|
||||||
|
"prompt_get_with_args",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"prompts/get",
|
||||||
|
"--prompt-name",
|
||||||
|
"complex_prompt",
|
||||||
|
"--prompt-args",
|
||||||
|
"temperature=0.7",
|
||||||
|
"style=concise",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 20: CLI mode with prompt get but missing prompt name (should fail)
|
||||||
|
await runErrorTest(
|
||||||
|
"missing_prompt_name",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"prompts/get",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n${colors.YELLOW}=== Running Logging Tests ===${colors.NC}`);
|
||||||
|
|
||||||
|
// Test 21: CLI mode with log level
|
||||||
|
await runBasicTest(
|
||||||
|
"log_level",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"logging/setLevel",
|
||||||
|
"--log-level",
|
||||||
|
"debug",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 22: CLI mode with invalid log level (should fail)
|
||||||
|
await runErrorTest(
|
||||||
|
"invalid_log_level",
|
||||||
|
TEST_CMD,
|
||||||
|
...TEST_ARGS,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"logging/setLevel",
|
||||||
|
"--log-level",
|
||||||
|
"invalid",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n${colors.YELLOW}=== Running Combined Option Tests ===${colors.NC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note about the combined options issue
|
||||||
|
console.log(
|
||||||
|
`${colors.BLUE}Testing combined options with environment variables and config file.${colors.NC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 23: CLI mode with config file, environment variables, and tool call
|
||||||
|
await runBasicTest(
|
||||||
|
"combined_options",
|
||||||
|
"--config",
|
||||||
|
path.join(PROJECT_ROOT, "sample-config.json"),
|
||||||
|
"--server",
|
||||||
|
"everything",
|
||||||
|
"-e",
|
||||||
|
"CLI_ENV_VAR=cli_value",
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 24: CLI mode with all possible options (that make sense together)
|
||||||
|
await runBasicTest(
|
||||||
|
"all_options",
|
||||||
|
"--config",
|
||||||
|
path.join(PROJECT_ROOT, "sample-config.json"),
|
||||||
|
"--server",
|
||||||
|
"everything",
|
||||||
|
"-e",
|
||||||
|
"CLI_ENV_VAR=cli_value",
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/call",
|
||||||
|
"--tool-name",
|
||||||
|
"echo",
|
||||||
|
"--tool-arg",
|
||||||
|
"message=Hello",
|
||||||
|
"--log-level",
|
||||||
|
"debug",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Print test summary
|
||||||
|
console.log(`\n${colors.YELLOW}=== Test Summary ===${colors.NC}`);
|
||||||
|
console.log(`${colors.GREEN}Passed: ${PASSED_TESTS}${colors.NC}`);
|
||||||
|
console.log(`${colors.RED}Failed: ${FAILED_TESTS}${colors.NC}`);
|
||||||
|
console.log(`${colors.ORANGE}Skipped: ${SKIPPED_TESTS}${colors.NC}`);
|
||||||
|
console.log(`Total: ${TOTAL_TESTS}`);
|
||||||
|
console.log(
|
||||||
|
`${colors.BLUE}Detailed logs saved to: ${OUTPUT_DIR}${colors.NC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n${colors.GREEN}All tests completed!${colors.NC}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all tests
|
||||||
|
runTests().catch((error) => {
|
||||||
|
console.error(
|
||||||
|
`${colors.RED}Tests failed with error: ${error.message}${colors.NC}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
29
cli/scripts/make-executable.js
Executable file
29
cli/scripts/make-executable.js
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Cross-platform script to make a file executable
|
||||||
|
*/
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import { platform } from "os";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const TARGET_FILE = path.resolve("build/cli.js");
|
||||||
|
|
||||||
|
async function makeExecutable() {
|
||||||
|
try {
|
||||||
|
// On Unix-like systems (Linux, macOS), use chmod
|
||||||
|
if (platform() !== "win32") {
|
||||||
|
execSync(`chmod +x "${TARGET_FILE}"`);
|
||||||
|
console.log("Made file executable with chmod");
|
||||||
|
} else {
|
||||||
|
// On Windows, no need to make files "executable" in the Unix sense
|
||||||
|
// Just ensure the file exists
|
||||||
|
await fs.access(TARGET_FILE);
|
||||||
|
console.log("File exists and is accessible on Windows");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error making file executable:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
makeExecutable();
|
||||||
287
cli/src/cli.ts
Normal file
287
cli/src/cli.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { Command } from "commander";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { dirname, resolve } from "path";
|
||||||
|
import { spawnPromise } from "spawn-rx";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
envArgs: Record<string, string>;
|
||||||
|
cli: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CliOptions = {
|
||||||
|
e?: Record<string, string>;
|
||||||
|
config?: string;
|
||||||
|
server?: string;
|
||||||
|
cli?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ServerConfig = {
|
||||||
|
command: string;
|
||||||
|
args?: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleError(error: unknown): never {
|
||||||
|
let message: string;
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message = error.message;
|
||||||
|
} else if (typeof error === "string") {
|
||||||
|
message = error;
|
||||||
|
} else {
|
||||||
|
message = "Unknown error";
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(message);
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWebClient(args: Args): Promise<void> {
|
||||||
|
const inspectorServerPath = resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../",
|
||||||
|
"server",
|
||||||
|
"build",
|
||||||
|
"index.js",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Path to the client entry point
|
||||||
|
const inspectorClientPath = resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../",
|
||||||
|
"client",
|
||||||
|
"bin",
|
||||||
|
"client.js",
|
||||||
|
);
|
||||||
|
|
||||||
|
const CLIENT_PORT: string = process.env.CLIENT_PORT ?? "6274";
|
||||||
|
const SERVER_PORT: string = process.env.SERVER_PORT ?? "6277";
|
||||||
|
|
||||||
|
console.log("Starting MCP inspector...");
|
||||||
|
|
||||||
|
const abort = new AbortController();
|
||||||
|
let cancelled: boolean = false;
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
cancelled = true;
|
||||||
|
abort.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
let server: ReturnType<typeof spawnPromise>;
|
||||||
|
let serverOk: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
server = spawnPromise(
|
||||||
|
"node",
|
||||||
|
[
|
||||||
|
inspectorServerPath,
|
||||||
|
...(args.command ? [`--env`, args.command] : []),
|
||||||
|
...(args.args ? [`--args=${args.args.join(" ")}`] : []),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: SERVER_PORT,
|
||||||
|
MCP_ENV_VARS: JSON.stringify(args.envArgs),
|
||||||
|
},
|
||||||
|
signal: abort.signal,
|
||||||
|
echoOutput: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Make sure server started before starting client
|
||||||
|
serverOk = await Promise.race([server, delay(2 * 1000)]);
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
|
if (serverOk) {
|
||||||
|
try {
|
||||||
|
await spawnPromise("node", [inspectorClientPath], {
|
||||||
|
env: { ...process.env, PORT: CLIENT_PORT },
|
||||||
|
signal: abort.signal,
|
||||||
|
echoOutput: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled || process.env.DEBUG) throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCli(args: Args): Promise<void> {
|
||||||
|
const projectRoot = resolve(__dirname, "..");
|
||||||
|
const cliPath = resolve(projectRoot, "build", "index.js");
|
||||||
|
|
||||||
|
const abort = new AbortController();
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
cancelled = true;
|
||||||
|
abort.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await spawnPromise("node", [cliPath, args.command, ...args.args], {
|
||||||
|
env: { ...process.env, ...args.envArgs },
|
||||||
|
signal: abort.signal,
|
||||||
|
echoOutput: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled || process.env.DEBUG) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfigFile(configPath: string, serverName: string): ServerConfig {
|
||||||
|
try {
|
||||||
|
const resolvedConfigPath = path.isAbsolute(configPath)
|
||||||
|
? configPath
|
||||||
|
: path.resolve(process.cwd(), configPath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(resolvedConfigPath)) {
|
||||||
|
throw new Error(`Config file not found: ${resolvedConfigPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configContent = fs.readFileSync(resolvedConfigPath, "utf8");
|
||||||
|
const parsedConfig = JSON.parse(configContent);
|
||||||
|
|
||||||
|
if (!parsedConfig.mcpServers || !parsedConfig.mcpServers[serverName]) {
|
||||||
|
const availableServers = Object.keys(parsedConfig.mcpServers || {}).join(
|
||||||
|
", ",
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Server '${serverName}' not found in config file. Available servers: ${availableServers}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverConfig = parsedConfig.mcpServers[serverName];
|
||||||
|
|
||||||
|
return serverConfig;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof SyntaxError) {
|
||||||
|
throw new Error(`Invalid JSON in config file: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKeyValuePair(
|
||||||
|
value: string,
|
||||||
|
previous: Record<string, string> = {},
|
||||||
|
): Record<string, string> {
|
||||||
|
const parts = value.split("=");
|
||||||
|
const key = parts[0];
|
||||||
|
const val = parts.slice(1).join("=");
|
||||||
|
|
||||||
|
if (val === undefined || val === "") {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid parameter format: ${value}. Use key=value format.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...previous, [key as string]: val };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(): Args {
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
const argSeparatorIndex = process.argv.indexOf("--");
|
||||||
|
let preArgs = process.argv;
|
||||||
|
let postArgs: string[] = [];
|
||||||
|
|
||||||
|
if (argSeparatorIndex !== -1) {
|
||||||
|
preArgs = process.argv.slice(0, argSeparatorIndex);
|
||||||
|
postArgs = process.argv.slice(argSeparatorIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
program
|
||||||
|
.name("inspector-bin")
|
||||||
|
.allowExcessArguments()
|
||||||
|
.allowUnknownOption()
|
||||||
|
.option(
|
||||||
|
"-e <env>",
|
||||||
|
"environment variables in KEY=VALUE format",
|
||||||
|
parseKeyValuePair,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
.option("--config <path>", "config file path")
|
||||||
|
.option("--server <n>", "server name from config file")
|
||||||
|
.option("--cli", "enable CLI mode");
|
||||||
|
|
||||||
|
// Parse only the arguments before --
|
||||||
|
program.parse(preArgs);
|
||||||
|
|
||||||
|
const options = program.opts() as CliOptions;
|
||||||
|
const remainingArgs = program.args;
|
||||||
|
|
||||||
|
// Add back any arguments that came after --
|
||||||
|
const finalArgs = [...remainingArgs, ...postArgs];
|
||||||
|
|
||||||
|
// Validate that config and server are provided together
|
||||||
|
if (
|
||||||
|
(options.config && !options.server) ||
|
||||||
|
(!options.config && options.server)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Both --config and --server must be provided together. If you specify one, you must specify the other.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If config file is specified, load and use the options from the file. We must merge the args
|
||||||
|
// from the command line and the file together, or we will miss the method options (--method,
|
||||||
|
// etc.)
|
||||||
|
if (options.config && options.server) {
|
||||||
|
const config = loadConfigFile(options.config, options.server);
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: config.command,
|
||||||
|
args: [...(config.args || []), ...finalArgs],
|
||||||
|
envArgs: { ...(config.env || {}), ...(options.e || {}) },
|
||||||
|
cli: options.cli || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use command line arguments
|
||||||
|
const command = finalArgs[0] || "";
|
||||||
|
const args = finalArgs.slice(1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
envArgs: options.e || {},
|
||||||
|
cli: options.cli || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
handleError(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = parseArgs();
|
||||||
|
|
||||||
|
if (args.cli) {
|
||||||
|
runCli(args);
|
||||||
|
} else {
|
||||||
|
await runWebClient(args);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
51
cli/src/client/connection.ts
Normal file
51
cli/src/client/connection.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||||
|
import { McpResponse } from "./types.js";
|
||||||
|
|
||||||
|
export const validLogLevels = [
|
||||||
|
"trace",
|
||||||
|
"debug",
|
||||||
|
"info",
|
||||||
|
"warn",
|
||||||
|
"error",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type LogLevel = (typeof validLogLevels)[number];
|
||||||
|
|
||||||
|
export async function connect(
|
||||||
|
client: Client,
|
||||||
|
transport: Transport,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await client.connect(transport);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to connect to MCP server: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnect(transport: Transport): Promise<void> {
|
||||||
|
try {
|
||||||
|
await transport.close();
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to disconnect from MCP server: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set logging level
|
||||||
|
export async function setLoggingLevel(
|
||||||
|
client: Client,
|
||||||
|
level: LogLevel,
|
||||||
|
): Promise<McpResponse> {
|
||||||
|
try {
|
||||||
|
const response = await client.setLoggingLevel(level as any);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to set logging level: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
cli/src/client/index.ts
Normal file
6
cli/src/client/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Re-export everything from the client modules
|
||||||
|
export * from "./connection.js";
|
||||||
|
export * from "./prompts.js";
|
||||||
|
export * from "./resources.js";
|
||||||
|
export * from "./tools.js";
|
||||||
|
export * from "./types.js";
|
||||||
34
cli/src/client/prompts.ts
Normal file
34
cli/src/client/prompts.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { McpResponse } from "./types.js";
|
||||||
|
|
||||||
|
// List available prompts
|
||||||
|
export async function listPrompts(client: Client): Promise<McpResponse> {
|
||||||
|
try {
|
||||||
|
const response = await client.listPrompts();
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to list prompts: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a prompt
|
||||||
|
export async function getPrompt(
|
||||||
|
client: Client,
|
||||||
|
name: string,
|
||||||
|
args?: Record<string, string>,
|
||||||
|
): Promise<McpResponse> {
|
||||||
|
try {
|
||||||
|
const response = await client.getPrompt({
|
||||||
|
name,
|
||||||
|
arguments: args || {},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to get prompt: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
cli/src/client/resources.ts
Normal file
43
cli/src/client/resources.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { McpResponse } from "./types.js";
|
||||||
|
|
||||||
|
// List available resources
|
||||||
|
export async function listResources(client: Client): Promise<McpResponse> {
|
||||||
|
try {
|
||||||
|
const response = await client.listResources();
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to list resources: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a resource
|
||||||
|
export async function readResource(
|
||||||
|
client: Client,
|
||||||
|
uri: string,
|
||||||
|
): Promise<McpResponse> {
|
||||||
|
try {
|
||||||
|
const response = await client.readResource({ uri });
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to read resource ${uri}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List resource templates
|
||||||
|
export async function listResourceTemplates(
|
||||||
|
client: Client,
|
||||||
|
): Promise<McpResponse> {
|
||||||
|
try {
|
||||||
|
const response = await client.listResourceTemplates();
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to list resource templates: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
cli/src/client/tools.ts
Normal file
95
cli/src/client/tools.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { McpResponse } from "./types.js";
|
||||||
|
|
||||||
|
type JsonSchemaType = {
|
||||||
|
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
|
||||||
|
description?: string;
|
||||||
|
properties?: Record<string, JsonSchemaType>;
|
||||||
|
items?: JsonSchemaType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listTools(client: Client): Promise<McpResponse> {
|
||||||
|
try {
|
||||||
|
const response = await client.listTools();
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to list tools: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertParameterValue(value: string, schema: JsonSchemaType): unknown {
|
||||||
|
if (!value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === "number" || schema.type === "integer") {
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === "boolean") {
|
||||||
|
return value.toLowerCase() === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === "object" || schema.type === "array") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (error) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertParameters(
|
||||||
|
tool: Tool,
|
||||||
|
params: Record<string, string>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
const properties = tool.inputSchema.properties || {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
const paramSchema = properties[key] as JsonSchemaType | undefined;
|
||||||
|
|
||||||
|
if (paramSchema) {
|
||||||
|
result[key] = convertParameterValue(value, paramSchema);
|
||||||
|
} else {
|
||||||
|
// If no schema is found for this parameter, keep it as string
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callTool(
|
||||||
|
client: Client,
|
||||||
|
name: string,
|
||||||
|
args: Record<string, string>,
|
||||||
|
): Promise<McpResponse> {
|
||||||
|
try {
|
||||||
|
const toolsResponse = await listTools(client);
|
||||||
|
const tools = toolsResponse.tools as Tool[];
|
||||||
|
const tool = tools.find((t) => t.name === name);
|
||||||
|
|
||||||
|
let convertedArgs: Record<string, unknown> = args;
|
||||||
|
|
||||||
|
if (tool) {
|
||||||
|
// Convert parameters based on the tool's schema
|
||||||
|
convertedArgs = convertParameters(tool, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: name,
|
||||||
|
arguments: convertedArgs,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to call tool ${name}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
cli/src/client/types.ts
Normal file
1
cli/src/client/types.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type McpResponse = Record<string, unknown>;
|
||||||
20
cli/src/error-handler.ts
Normal file
20
cli/src/error-handler.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
function formatError(error: unknown): string {
|
||||||
|
let message: string;
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message = error.message;
|
||||||
|
} else if (typeof error === "string") {
|
||||||
|
message = error;
|
||||||
|
} else {
|
||||||
|
message = "Unknown error";
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleError(error: unknown): never {
|
||||||
|
const errorMessage = formatError(error);
|
||||||
|
console.error(errorMessage);
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
253
cli/src/index.ts
Normal file
253
cli/src/index.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { Command } from "commander";
|
||||||
|
import {
|
||||||
|
callTool,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
getPrompt,
|
||||||
|
listPrompts,
|
||||||
|
listResources,
|
||||||
|
listResourceTemplates,
|
||||||
|
listTools,
|
||||||
|
LogLevel,
|
||||||
|
McpResponse,
|
||||||
|
readResource,
|
||||||
|
setLoggingLevel,
|
||||||
|
validLogLevels,
|
||||||
|
} from "./client/index.js";
|
||||||
|
import { handleError } from "./error-handler.js";
|
||||||
|
import { createTransport, TransportOptions } from "./transport.js";
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
target: string[];
|
||||||
|
method?: string;
|
||||||
|
promptName?: string;
|
||||||
|
promptArgs?: Record<string, string>;
|
||||||
|
uri?: string;
|
||||||
|
logLevel?: LogLevel;
|
||||||
|
toolName?: string;
|
||||||
|
toolArg?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createTransportOptions(target: string[]): TransportOptions {
|
||||||
|
if (target.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Target is required. Specify a URL or a command to execute.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [command, ...commandArgs] = target;
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
throw new Error("Command is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUrl = command.startsWith("http://") || command.startsWith("https://");
|
||||||
|
|
||||||
|
if (isUrl && commandArgs.length > 0) {
|
||||||
|
throw new Error("Arguments cannot be passed to a URL-based MCP server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
transportType: isUrl ? "sse" : "stdio",
|
||||||
|
command: isUrl ? undefined : command,
|
||||||
|
args: isUrl ? undefined : commandArgs,
|
||||||
|
url: isUrl ? command : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callMethod(args: Args): Promise<void> {
|
||||||
|
const transportOptions = createTransportOptions(args.target);
|
||||||
|
const transport = createTransport(transportOptions);
|
||||||
|
const client = new Client({
|
||||||
|
name: "inspector-cli",
|
||||||
|
version: "0.5.1",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connect(client, transport);
|
||||||
|
|
||||||
|
let result: McpResponse;
|
||||||
|
|
||||||
|
// Tools methods
|
||||||
|
if (args.method === "tools/list") {
|
||||||
|
result = await listTools(client);
|
||||||
|
} else if (args.method === "tools/call") {
|
||||||
|
if (!args.toolName) {
|
||||||
|
throw new Error(
|
||||||
|
"Tool name is required for tools/call method. Use --tool-name to specify the tool name.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await callTool(client, args.toolName, args.toolArg || {});
|
||||||
|
}
|
||||||
|
// Resources methods
|
||||||
|
else if (args.method === "resources/list") {
|
||||||
|
result = await listResources(client);
|
||||||
|
} else if (args.method === "resources/read") {
|
||||||
|
if (!args.uri) {
|
||||||
|
throw new Error(
|
||||||
|
"URI is required for resources/read method. Use --uri to specify the resource URI.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await readResource(client, args.uri);
|
||||||
|
} else if (args.method === "resources/templates/list") {
|
||||||
|
result = await listResourceTemplates(client);
|
||||||
|
}
|
||||||
|
// Prompts methods
|
||||||
|
else if (args.method === "prompts/list") {
|
||||||
|
result = await listPrompts(client);
|
||||||
|
} else if (args.method === "prompts/get") {
|
||||||
|
if (!args.promptName) {
|
||||||
|
throw new Error(
|
||||||
|
"Prompt name is required for prompts/get method. Use --prompt-name to specify the prompt name.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await getPrompt(client, args.promptName, args.promptArgs || {});
|
||||||
|
}
|
||||||
|
// Logging methods
|
||||||
|
else if (args.method === "logging/setLevel") {
|
||||||
|
if (!args.logLevel) {
|
||||||
|
throw new Error(
|
||||||
|
"Log level is required for logging/setLevel method. Use --log-level to specify the log level.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await setLoggingLevel(client, args.logLevel);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported method: ${args.method}. Supported methods include: tools/list, tools/call, resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, logging/setLevel`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await disconnect(transport);
|
||||||
|
} catch (disconnectError) {
|
||||||
|
throw disconnectError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKeyValuePair(
|
||||||
|
value: string,
|
||||||
|
previous: Record<string, string> = {},
|
||||||
|
): Record<string, string> {
|
||||||
|
const parts = value.split("=");
|
||||||
|
const key = parts[0];
|
||||||
|
const val = parts.slice(1).join("=");
|
||||||
|
|
||||||
|
if (val === undefined || val === "") {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid parameter format: ${value}. Use key=value format.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...previous, [key as string]: val };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(): Args {
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
// Find if there's a -- in the arguments and split them
|
||||||
|
const argSeparatorIndex = process.argv.indexOf("--");
|
||||||
|
let preArgs = process.argv;
|
||||||
|
let postArgs: string[] = [];
|
||||||
|
|
||||||
|
if (argSeparatorIndex !== -1) {
|
||||||
|
preArgs = process.argv.slice(0, argSeparatorIndex);
|
||||||
|
postArgs = process.argv.slice(argSeparatorIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
program
|
||||||
|
.name("inspector-cli")
|
||||||
|
.allowUnknownOption()
|
||||||
|
.argument("<target...>", "Command and arguments or URL of the MCP server")
|
||||||
|
//
|
||||||
|
// Method selection
|
||||||
|
//
|
||||||
|
.option("--method <method>", "Method to invoke")
|
||||||
|
//
|
||||||
|
// Tool-related options
|
||||||
|
//
|
||||||
|
.option("--tool-name <toolName>", "Tool name (for tools/call method)")
|
||||||
|
.option(
|
||||||
|
"--tool-arg <pairs...>",
|
||||||
|
"Tool argument as key=value pair",
|
||||||
|
parseKeyValuePair,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
//
|
||||||
|
// Resource-related options
|
||||||
|
//
|
||||||
|
.option("--uri <uri>", "URI of the resource (for resources/read method)")
|
||||||
|
//
|
||||||
|
// Prompt-related options
|
||||||
|
//
|
||||||
|
.option(
|
||||||
|
"--prompt-name <promptName>",
|
||||||
|
"Name of the prompt (for prompts/get method)",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--prompt-args <pairs...>",
|
||||||
|
"Prompt arguments as key=value pairs",
|
||||||
|
parseKeyValuePair,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
//
|
||||||
|
// Logging options
|
||||||
|
//
|
||||||
|
.option(
|
||||||
|
"--log-level <level>",
|
||||||
|
"Logging level (for logging/setLevel method)",
|
||||||
|
(value: string) => {
|
||||||
|
if (!validLogLevels.includes(value as any)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid log level: ${value}. Valid levels are: ${validLogLevels.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value as LogLevel;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse only the arguments before --
|
||||||
|
program.parse(preArgs);
|
||||||
|
|
||||||
|
const options = program.opts() as Omit<Args, "target">;
|
||||||
|
let remainingArgs = program.args;
|
||||||
|
|
||||||
|
// Add back any arguments that came after --
|
||||||
|
const finalArgs = [...remainingArgs, ...postArgs];
|
||||||
|
|
||||||
|
if (!options.method) {
|
||||||
|
throw new Error(
|
||||||
|
"Method is required. Use --method to specify the method to invoke.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
target: finalArgs,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
handleError(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = parseArgs();
|
||||||
|
await callMethod(args);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
76
cli/src/transport.ts
Normal file
76
cli/src/transport.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||||
|
import {
|
||||||
|
getDefaultEnvironment,
|
||||||
|
StdioClientTransport,
|
||||||
|
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||||
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||||
|
import { findActualExecutable } from "spawn-rx";
|
||||||
|
|
||||||
|
export type TransportOptions = {
|
||||||
|
transportType: "sse" | "stdio";
|
||||||
|
command?: string;
|
||||||
|
args?: string[];
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createSSETransport(options: TransportOptions): Transport {
|
||||||
|
const baseUrl = new URL(options.url ?? "");
|
||||||
|
const sseUrl = new URL("/sse", baseUrl);
|
||||||
|
|
||||||
|
return new SSEClientTransport(sseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStdioTransport(options: TransportOptions): Transport {
|
||||||
|
let args: string[] = [];
|
||||||
|
|
||||||
|
if (options.args !== undefined) {
|
||||||
|
args = options.args;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processEnv: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
processEnv[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultEnv = getDefaultEnvironment();
|
||||||
|
|
||||||
|
const env: Record<string, string> = {
|
||||||
|
...processEnv,
|
||||||
|
...defaultEnv,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { cmd: actualCommand, args: actualArgs } = findActualExecutable(
|
||||||
|
options.command ?? "",
|
||||||
|
args,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new StdioClientTransport({
|
||||||
|
command: actualCommand,
|
||||||
|
args: actualArgs,
|
||||||
|
env,
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTransport(options: TransportOptions): Transport {
|
||||||
|
const { transportType } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (transportType === "stdio") {
|
||||||
|
return createStdioTransport(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transportType === "sse") {
|
||||||
|
return createSSETransport(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported transport type: ${transportType}`);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to create transport: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
cli/tsconfig.json
Normal file
17
cli/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "./build",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noUncheckedIndexedAccess": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "packages", "**/*.spec.ts", "build"]
|
||||||
|
}
|
||||||
@@ -9,10 +9,34 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||||||
const distPath = join(__dirname, "../dist");
|
const distPath = join(__dirname, "../dist");
|
||||||
|
|
||||||
const server = http.createServer((request, response) => {
|
const server = http.createServer((request, response) => {
|
||||||
return handler(request, response, {
|
const handlerOptions = {
|
||||||
public: distPath,
|
public: distPath,
|
||||||
rewrites: [{ source: "/**", destination: "/index.html" }],
|
rewrites: [{ source: "/**", destination: "/index.html" }],
|
||||||
});
|
headers: [
|
||||||
|
{
|
||||||
|
// Ensure index.html is never cached
|
||||||
|
source: "index.html",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "Cache-Control",
|
||||||
|
value: "no-cache, no-store, max-age=0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Allow long-term caching for hashed assets
|
||||||
|
source: "assets/**",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "Cache-Control",
|
||||||
|
value: "public, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return handler(request, response, handlerOptions);
|
||||||
});
|
});
|
||||||
|
|
||||||
const port = process.env.PORT || 6274;
|
const port = process.env.PORT || 6274;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import open from "open";
|
||||||
import { resolve, dirname } from "path";
|
import { resolve, dirname } from "path";
|
||||||
import { spawnPromise } from "spawn-rx";
|
import { spawnPromise } from "spawn-rx";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
@@ -46,7 +47,7 @@ async function main() {
|
|||||||
|
|
||||||
const inspectorServerPath = resolve(
|
const inspectorServerPath = resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
"..",
|
"../..",
|
||||||
"server",
|
"server",
|
||||||
"build",
|
"build",
|
||||||
"index.js",
|
"index.js",
|
||||||
@@ -55,10 +56,10 @@ async function main() {
|
|||||||
// Path to the client entry point
|
// Path to the client entry point
|
||||||
const inspectorClientPath = resolve(
|
const inspectorClientPath = resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
"..",
|
"../..",
|
||||||
"client",
|
"client",
|
||||||
"bin",
|
"bin",
|
||||||
"cli.js",
|
"client.js",
|
||||||
);
|
);
|
||||||
|
|
||||||
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
|
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
|
||||||
@@ -99,6 +100,9 @@ async function main() {
|
|||||||
|
|
||||||
if (serverOk) {
|
if (serverOk) {
|
||||||
try {
|
try {
|
||||||
|
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
|
||||||
|
open(`http://127.0.0.1:${CLIENT_PORT}`);
|
||||||
|
}
|
||||||
await spawnPromise("node", [inspectorClientPath], {
|
await spawnPromise("node", [inspectorClientPath], {
|
||||||
env: { ...process.env, PORT: CLIENT_PORT },
|
env: { ...process.env, PORT: CLIENT_PORT },
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
preset: "ts-jest",
|
preset: "ts-jest",
|
||||||
testEnvironment: "jsdom",
|
testEnvironment: "jest-fixed-jsdom",
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
"^@/(.*)$": "<rootDir>/src/$1",
|
"^@/(.*)$": "<rootDir>/src/$1",
|
||||||
"\\.css$": "<rootDir>/src/__mocks__/styleMock.js",
|
"\\.css$": "<rootDir>/src/__mocks__/styleMock.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "0.8.1",
|
"version": "0.12.0",
|
||||||
"description": "Client-side application for the Model Context Protocol inspector",
|
"description": "Client-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
@@ -8,14 +8,14 @@
|
|||||||
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
|
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"mcp-inspector-client": "./bin/cli.js"
|
"mcp-inspector-client": "./bin/start.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"bin",
|
"bin",
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --port 6274",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview --port 6274",
|
"preview": "vite preview --port 6274",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"test:watch": "jest --config jest.config.cjs --watch"
|
"test:watch": "jest --config jest.config.cjs --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.8.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.3",
|
"@radix-ui/react-dialog": "^1.1.3",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
@@ -32,9 +32,8 @@
|
|||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.0.4",
|
||||||
@@ -52,9 +51,10 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.11.1",
|
"@eslint/js": "^9.11.1",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^14.2.1",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.7.5",
|
"@types/node": "^22.7.5",
|
||||||
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "^18.3.10",
|
"@types/react": "^18.3.10",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/serve-handler": "^6.1.4",
|
"@types/serve-handler": "^6.1.4",
|
||||||
@@ -72,6 +72,6 @@
|
|||||||
"ts-jest": "^29.2.6",
|
"ts-jest": "^29.2.6",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.7.0",
|
"typescript-eslint": "^8.7.0",
|
||||||
"vite": "^5.4.8"
|
"vite": "^6.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,23 +17,35 @@ import {
|
|||||||
Tool,
|
Tool,
|
||||||
LoggingLevel,
|
LoggingLevel,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import React, { Suspense, useEffect, useRef, useState } from "react";
|
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
|
||||||
|
import { AuthDebuggerState } from "./lib/auth-types";
|
||||||
|
import React, {
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useConnection } from "./lib/hooks/useConnection";
|
import { useConnection } from "./lib/hooks/useConnection";
|
||||||
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
|
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
|
||||||
import { StdErrNotification } from "./lib/notificationTypes";
|
import { StdErrNotification } from "./lib/notificationTypes";
|
||||||
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
Files,
|
Files,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
Hammer,
|
Hammer,
|
||||||
Hash,
|
Hash,
|
||||||
|
Key,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
import AuthDebugger from "./components/AuthDebugger";
|
||||||
import ConsoleTab from "./components/ConsoleTab";
|
import ConsoleTab from "./components/ConsoleTab";
|
||||||
import HistoryAndNotifications from "./components/History";
|
import HistoryAndNotifications from "./components/History";
|
||||||
import PingTab from "./components/PingTab";
|
import PingTab from "./components/PingTab";
|
||||||
@@ -43,20 +55,19 @@ import RootsTab from "./components/RootsTab";
|
|||||||
import SamplingTab, { PendingRequest } from "./components/SamplingTab";
|
import SamplingTab, { PendingRequest } from "./components/SamplingTab";
|
||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
import ToolsTab from "./components/ToolsTab";
|
import ToolsTab from "./components/ToolsTab";
|
||||||
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
|
|
||||||
import { InspectorConfig } from "./lib/configurationTypes";
|
import { InspectorConfig } from "./lib/configurationTypes";
|
||||||
import {
|
import {
|
||||||
getMCPProxyAddress,
|
getMCPProxyAddress,
|
||||||
getMCPServerRequestTimeout,
|
getInitialSseUrl,
|
||||||
|
getInitialTransportType,
|
||||||
|
getInitialCommand,
|
||||||
|
getInitialArgs,
|
||||||
|
initializeInspectorConfig,
|
||||||
} from "./utils/configUtils";
|
} from "./utils/configUtils";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const { toast } = useToast();
|
|
||||||
// Handle OAuth callback route
|
|
||||||
const [resources, setResources] = useState<Resource[]>([]);
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
const [resourceTemplates, setResourceTemplates] = useState<
|
const [resourceTemplates, setResourceTemplates] = useState<
|
||||||
ResourceTemplate[]
|
ResourceTemplate[]
|
||||||
@@ -72,21 +83,13 @@ const App = () => {
|
|||||||
prompts: null,
|
prompts: null,
|
||||||
tools: null,
|
tools: null,
|
||||||
});
|
});
|
||||||
const [command, setCommand] = useState<string>(() => {
|
const [command, setCommand] = useState<string>(getInitialCommand);
|
||||||
return localStorage.getItem("lastCommand") || "mcp-server-everything";
|
const [args, setArgs] = useState<string>(getInitialArgs);
|
||||||
});
|
|
||||||
const [args, setArgs] = useState<string>(() => {
|
|
||||||
return localStorage.getItem("lastArgs") || "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const [sseUrl, setSseUrl] = useState<string>(() => {
|
const [sseUrl, setSseUrl] = useState<string>(getInitialSseUrl);
|
||||||
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
|
const [transportType, setTransportType] = useState<
|
||||||
});
|
"stdio" | "sse" | "streamable-http"
|
||||||
const [transportType, setTransportType] = useState<"stdio" | "sse">(() => {
|
>(getInitialTransportType);
|
||||||
return (
|
|
||||||
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
|
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
|
||||||
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
||||||
const [stdErrNotifications, setStdErrNotifications] = useState<
|
const [stdErrNotifications, setStdErrNotifications] = useState<
|
||||||
@@ -95,20 +98,22 @@ const App = () => {
|
|||||||
const [roots, setRoots] = useState<Root[]>([]);
|
const [roots, setRoots] = useState<Root[]>([]);
|
||||||
const [env, setEnv] = useState<Record<string, string>>({});
|
const [env, setEnv] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const [config, setConfig] = useState<InspectorConfig>(() => {
|
const [config, setConfig] = useState<InspectorConfig>(() =>
|
||||||
const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY);
|
initializeInspectorConfig(CONFIG_LOCAL_STORAGE_KEY),
|
||||||
if (savedConfig) {
|
);
|
||||||
return {
|
|
||||||
...DEFAULT_INSPECTOR_CONFIG,
|
|
||||||
...JSON.parse(savedConfig),
|
|
||||||
} as InspectorConfig;
|
|
||||||
}
|
|
||||||
return DEFAULT_INSPECTOR_CONFIG;
|
|
||||||
});
|
|
||||||
const [bearerToken, setBearerToken] = useState<string>(() => {
|
const [bearerToken, setBearerToken] = useState<string>(() => {
|
||||||
return localStorage.getItem("lastBearerToken") || "";
|
return localStorage.getItem("lastBearerToken") || "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [headerName, setHeaderName] = useState<string>(() => {
|
||||||
|
return localStorage.getItem("lastHeaderName") || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => {
|
||||||
|
const saved = localStorage.getItem("lastCustomHeaders");
|
||||||
|
return saved ? JSON.parse(saved) : [];
|
||||||
|
});
|
||||||
|
|
||||||
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
||||||
Array<
|
Array<
|
||||||
PendingRequest & {
|
PendingRequest & {
|
||||||
@@ -117,6 +122,27 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
>([]);
|
>([]);
|
||||||
|
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);
|
||||||
|
|
||||||
|
// Auth debugger state
|
||||||
|
const [authState, setAuthState] = useState<AuthDebuggerState>({
|
||||||
|
isInitiatingAuth: false,
|
||||||
|
oauthTokens: null,
|
||||||
|
loading: true,
|
||||||
|
oauthStep: "metadata_discovery",
|
||||||
|
oauthMetadata: null,
|
||||||
|
oauthClientInfo: null,
|
||||||
|
authorizationUrl: null,
|
||||||
|
authorizationCode: "",
|
||||||
|
latestError: null,
|
||||||
|
statusMessage: null,
|
||||||
|
validationError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to update specific auth state properties
|
||||||
|
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
|
||||||
|
setAuthState((prev) => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
const nextRequestId = useRef(0);
|
const nextRequestId = useRef(0);
|
||||||
const rootsRef = useRef<Root[]>([]);
|
const rootsRef = useRef<Root[]>([]);
|
||||||
|
|
||||||
@@ -148,7 +174,7 @@ const App = () => {
|
|||||||
serverCapabilities,
|
serverCapabilities,
|
||||||
mcpClient,
|
mcpClient,
|
||||||
requestHistory,
|
requestHistory,
|
||||||
makeRequest: makeConnectionRequest,
|
makeRequest,
|
||||||
sendNotification,
|
sendNotification,
|
||||||
handleCompletion,
|
handleCompletion,
|
||||||
completionsSupported,
|
completionsSupported,
|
||||||
@@ -161,8 +187,9 @@ const App = () => {
|
|||||||
sseUrl,
|
sseUrl,
|
||||||
env,
|
env,
|
||||||
bearerToken,
|
bearerToken,
|
||||||
proxyServerUrl: getMCPProxyAddress(config),
|
headerName,
|
||||||
requestTimeout: getMCPServerRequestTimeout(config),
|
customHeaders,
|
||||||
|
config,
|
||||||
onNotification: (notification) => {
|
onNotification: (notification) => {
|
||||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||||
},
|
},
|
||||||
@@ -201,35 +228,80 @@ const App = () => {
|
|||||||
localStorage.setItem("lastBearerToken", bearerToken);
|
localStorage.setItem("lastBearerToken", bearerToken);
|
||||||
}, [bearerToken]);
|
}, [bearerToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("lastHeaderName", headerName);
|
||||||
|
}, [headerName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders));
|
||||||
|
}, [customHeaders]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
|
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const hasProcessedRef = useRef(false);
|
// Auto-connect to previously saved serverURL after OAuth callback
|
||||||
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
|
const onOAuthConnect = useCallback(
|
||||||
useEffect(() => {
|
(serverUrl: string) => {
|
||||||
if (hasProcessedRef.current) {
|
|
||||||
// Only try to connect once
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const serverUrl = params.get("serverUrl");
|
|
||||||
if (serverUrl) {
|
|
||||||
setSseUrl(serverUrl);
|
setSseUrl(serverUrl);
|
||||||
setTransportType("sse");
|
setTransportType("sse");
|
||||||
// Remove serverUrl from URL without reloading the page
|
setIsAuthDebuggerVisible(false);
|
||||||
const newUrl = new URL(window.location.href);
|
void connectMcpServer();
|
||||||
newUrl.searchParams.delete("serverUrl");
|
},
|
||||||
window.history.replaceState({}, "", newUrl.toString());
|
[connectMcpServer],
|
||||||
// Show success toast for OAuth
|
);
|
||||||
toast({
|
|
||||||
title: "Success",
|
// Update OAuth debug state during debug callback
|
||||||
description: "Successfully authenticated with OAuth",
|
const onOAuthDebugConnect = useCallback(
|
||||||
|
({
|
||||||
|
authorizationCode,
|
||||||
|
errorMsg,
|
||||||
|
}: {
|
||||||
|
authorizationCode?: string;
|
||||||
|
errorMsg?: string;
|
||||||
|
}) => {
|
||||||
|
setIsAuthDebuggerVisible(true);
|
||||||
|
if (authorizationCode) {
|
||||||
|
updateAuthState({
|
||||||
|
authorizationCode,
|
||||||
|
oauthStep: "token_request",
|
||||||
});
|
});
|
||||||
hasProcessedRef.current = true;
|
|
||||||
// Connect to the server
|
|
||||||
connectMcpServer();
|
|
||||||
}
|
}
|
||||||
}, [connectMcpServer, toast]);
|
if (errorMsg) {
|
||||||
|
updateAuthState({
|
||||||
|
latestError: new Error(errorMsg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load OAuth tokens when sseUrl changes
|
||||||
|
useEffect(() => {
|
||||||
|
const loadOAuthTokens = async () => {
|
||||||
|
try {
|
||||||
|
if (sseUrl) {
|
||||||
|
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, sseUrl);
|
||||||
|
const tokens = sessionStorage.getItem(key);
|
||||||
|
if (tokens) {
|
||||||
|
const parsedTokens = await OAuthTokensSchema.parseAsync(
|
||||||
|
JSON.parse(tokens),
|
||||||
|
);
|
||||||
|
updateAuthState({
|
||||||
|
oauthTokens: parsedTokens,
|
||||||
|
oauthStep: "complete",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading OAuth tokens:", error);
|
||||||
|
} finally {
|
||||||
|
updateAuthState({ loading: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadOAuthTokens();
|
||||||
|
}, [sseUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${getMCPProxyAddress(config)}/config`)
|
fetch(`${getMCPProxyAddress(config)}/config`)
|
||||||
@@ -279,13 +351,13 @@ const App = () => {
|
|||||||
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeRequest = async <T extends z.ZodType>(
|
const sendMCPRequest = async <T extends z.ZodType>(
|
||||||
request: ClientRequest,
|
request: ClientRequest,
|
||||||
schema: T,
|
schema: T,
|
||||||
tabKey?: keyof typeof errors,
|
tabKey?: keyof typeof errors,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await makeConnectionRequest(request, schema);
|
const response = await makeRequest(request, schema);
|
||||||
if (tabKey !== undefined) {
|
if (tabKey !== undefined) {
|
||||||
clearError(tabKey);
|
clearError(tabKey);
|
||||||
}
|
}
|
||||||
@@ -303,7 +375,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const listResources = async () => {
|
const listResources = async () => {
|
||||||
const response = await makeRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "resources/list" as const,
|
method: "resources/list" as const,
|
||||||
params: nextResourceCursor ? { cursor: nextResourceCursor } : {},
|
params: nextResourceCursor ? { cursor: nextResourceCursor } : {},
|
||||||
@@ -316,7 +388,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const listResourceTemplates = async () => {
|
const listResourceTemplates = async () => {
|
||||||
const response = await makeRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "resources/templates/list" as const,
|
method: "resources/templates/list" as const,
|
||||||
params: nextResourceTemplateCursor
|
params: nextResourceTemplateCursor
|
||||||
@@ -333,7 +405,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const readResource = async (uri: string) => {
|
const readResource = async (uri: string) => {
|
||||||
const response = await makeRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "resources/read" as const,
|
method: "resources/read" as const,
|
||||||
params: { uri },
|
params: { uri },
|
||||||
@@ -346,7 +418,7 @@ const App = () => {
|
|||||||
|
|
||||||
const subscribeToResource = async (uri: string) => {
|
const subscribeToResource = async (uri: string) => {
|
||||||
if (!resourceSubscriptions.has(uri)) {
|
if (!resourceSubscriptions.has(uri)) {
|
||||||
await makeRequest(
|
await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "resources/subscribe" as const,
|
method: "resources/subscribe" as const,
|
||||||
params: { uri },
|
params: { uri },
|
||||||
@@ -362,7 +434,7 @@ const App = () => {
|
|||||||
|
|
||||||
const unsubscribeFromResource = async (uri: string) => {
|
const unsubscribeFromResource = async (uri: string) => {
|
||||||
if (resourceSubscriptions.has(uri)) {
|
if (resourceSubscriptions.has(uri)) {
|
||||||
await makeRequest(
|
await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "resources/unsubscribe" as const,
|
method: "resources/unsubscribe" as const,
|
||||||
params: { uri },
|
params: { uri },
|
||||||
@@ -377,7 +449,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const listPrompts = async () => {
|
const listPrompts = async () => {
|
||||||
const response = await makeRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "prompts/list" as const,
|
method: "prompts/list" as const,
|
||||||
params: nextPromptCursor ? { cursor: nextPromptCursor } : {},
|
params: nextPromptCursor ? { cursor: nextPromptCursor } : {},
|
||||||
@@ -390,7 +462,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
|
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
|
||||||
const response = await makeRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "prompts/get" as const,
|
method: "prompts/get" as const,
|
||||||
params: { name, arguments: args },
|
params: { name, arguments: args },
|
||||||
@@ -402,7 +474,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const listTools = async () => {
|
const listTools = async () => {
|
||||||
const response = await makeRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "tools/list" as const,
|
method: "tools/list" as const,
|
||||||
params: nextToolCursor ? { cursor: nextToolCursor } : {},
|
params: nextToolCursor ? { cursor: nextToolCursor } : {},
|
||||||
@@ -415,7 +487,8 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const callTool = async (name: string, params: Record<string, unknown>) => {
|
const callTool = async (name: string, params: Record<string, unknown>) => {
|
||||||
const response = await makeRequest(
|
try {
|
||||||
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "tools/call" as const,
|
method: "tools/call" as const,
|
||||||
params: {
|
params: {
|
||||||
@@ -430,6 +503,18 @@ const App = () => {
|
|||||||
"tools",
|
"tools",
|
||||||
);
|
);
|
||||||
setToolResult(response);
|
setToolResult(response);
|
||||||
|
} catch (e) {
|
||||||
|
const toolResult: CompatibilityCallToolResult = {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: (e as Error).message ?? String(e),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
setToolResult(toolResult);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRootsChange = async () => {
|
const handleRootsChange = async () => {
|
||||||
@@ -437,7 +522,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sendLogLevelRequest = async (level: LoggingLevel) => {
|
const sendLogLevelRequest = async (level: LoggingLevel) => {
|
||||||
await makeRequest(
|
await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "logging/setLevel" as const,
|
method: "logging/setLevel" as const,
|
||||||
params: { level },
|
params: { level },
|
||||||
@@ -447,13 +532,41 @@ const App = () => {
|
|||||||
setLogLevel(level);
|
setLogLevel(level);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearStdErrNotifications = () => {
|
||||||
|
setStdErrNotifications([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper component for rendering the AuthDebugger
|
||||||
|
const AuthDebuggerWrapper = () => (
|
||||||
|
<TabsContent value="auth">
|
||||||
|
<AuthDebugger
|
||||||
|
serverUrl={sseUrl}
|
||||||
|
onBack={() => setIsAuthDebuggerVisible(false)}
|
||||||
|
authState={authState}
|
||||||
|
updateAuthState={updateAuthState}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper function to render OAuth callback components
|
||||||
if (window.location.pathname === "/oauth/callback") {
|
if (window.location.pathname === "/oauth/callback") {
|
||||||
const OAuthCallback = React.lazy(
|
const OAuthCallback = React.lazy(
|
||||||
() => import("./components/OAuthCallback"),
|
() => import("./components/OAuthCallback"),
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
<OAuthCallback />
|
<OAuthCallback onConnect={onOAuthConnect} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.pathname === "/oauth/callback/debug") {
|
||||||
|
const OAuthDebugCallback = React.lazy(
|
||||||
|
() => import("./components/OAuthDebugCallback"),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<OAuthDebugCallback onConnect={onOAuthDebugConnect} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -476,12 +589,17 @@ const App = () => {
|
|||||||
setConfig={setConfig}
|
setConfig={setConfig}
|
||||||
bearerToken={bearerToken}
|
bearerToken={bearerToken}
|
||||||
setBearerToken={setBearerToken}
|
setBearerToken={setBearerToken}
|
||||||
|
headerName={headerName}
|
||||||
|
setHeaderName={setHeaderName}
|
||||||
|
customHeaders={customHeaders}
|
||||||
|
setCustomHeaders={setCustomHeaders}
|
||||||
onConnect={connectMcpServer}
|
onConnect={connectMcpServer}
|
||||||
onDisconnect={disconnectMcpServer}
|
onDisconnect={disconnectMcpServer}
|
||||||
stdErrNotifications={stdErrNotifications}
|
stdErrNotifications={stdErrNotifications}
|
||||||
logLevel={logLevel}
|
logLevel={logLevel}
|
||||||
sendLogLevelRequest={sendLogLevelRequest}
|
sendLogLevelRequest={sendLogLevelRequest}
|
||||||
loggingSupported={!!serverCapabilities?.logging || false}
|
loggingSupported={!!serverCapabilities?.logging || false}
|
||||||
|
clearStdErrNotifications={clearStdErrNotifications}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
@@ -542,17 +660,34 @@ const App = () => {
|
|||||||
<FolderTree className="w-4 h-4 mr-2" />
|
<FolderTree className="w-4 h-4 mr-2" />
|
||||||
Roots
|
Roots
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="auth">
|
||||||
|
<Key className="w-4 h-4 mr-2" />
|
||||||
|
Auth
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{!serverCapabilities?.resources &&
|
{!serverCapabilities?.resources &&
|
||||||
!serverCapabilities?.prompts &&
|
!serverCapabilities?.prompts &&
|
||||||
!serverCapabilities?.tools ? (
|
!serverCapabilities?.tools ? (
|
||||||
|
<>
|
||||||
<div className="flex items-center justify-center p-4">
|
<div className="flex items-center justify-center p-4">
|
||||||
<p className="text-lg text-gray-500">
|
<p className="text-lg text-gray-500">
|
||||||
The connected server does not support any MCP capabilities
|
The connected server does not support any MCP
|
||||||
|
capabilities
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<PingTab
|
||||||
|
onPingClick={() => {
|
||||||
|
void sendMCPRequest(
|
||||||
|
{
|
||||||
|
method: "ping" as const,
|
||||||
|
},
|
||||||
|
EmptyResultSchema,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ResourcesTab
|
<ResourcesTab
|
||||||
@@ -620,6 +755,7 @@ const App = () => {
|
|||||||
setSelectedPrompt={(prompt) => {
|
setSelectedPrompt={(prompt) => {
|
||||||
clearError("prompts");
|
clearError("prompts");
|
||||||
setSelectedPrompt(prompt);
|
setSelectedPrompt(prompt);
|
||||||
|
setPromptContent("");
|
||||||
}}
|
}}
|
||||||
handleCompletion={handleCompletion}
|
handleCompletion={handleCompletion}
|
||||||
completionsSupported={completionsSupported}
|
completionsSupported={completionsSupported}
|
||||||
@@ -637,9 +773,10 @@ const App = () => {
|
|||||||
setTools([]);
|
setTools([]);
|
||||||
setNextToolCursor(undefined);
|
setNextToolCursor(undefined);
|
||||||
}}
|
}}
|
||||||
callTool={(name, params) => {
|
callTool={async (name, params) => {
|
||||||
clearError("tools");
|
clearError("tools");
|
||||||
callTool(name, params);
|
setToolResult(null);
|
||||||
|
await callTool(name, params);
|
||||||
}}
|
}}
|
||||||
selectedTool={selectedTool}
|
selectedTool={selectedTool}
|
||||||
setSelectedTool={(tool) => {
|
setSelectedTool={(tool) => {
|
||||||
@@ -654,7 +791,7 @@ const App = () => {
|
|||||||
<ConsoleTab />
|
<ConsoleTab />
|
||||||
<PingTab
|
<PingTab
|
||||||
onPingClick={() => {
|
onPingClick={() => {
|
||||||
void makeRequest(
|
void sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "ping" as const,
|
method: "ping" as const,
|
||||||
},
|
},
|
||||||
@@ -672,15 +809,36 @@ const App = () => {
|
|||||||
setRoots={setRoots}
|
setRoots={setRoots}
|
||||||
onRootsChange={handleRootsChange}
|
onRootsChange={handleRootsChange}
|
||||||
/>
|
/>
|
||||||
|
<AuthDebuggerWrapper />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
) : isAuthDebuggerVisible ? (
|
||||||
|
<Tabs
|
||||||
|
defaultValue={"auth"}
|
||||||
|
className="w-full p-4"
|
||||||
|
onValueChange={(value) => (window.location.hash = value)}
|
||||||
|
>
|
||||||
|
<AuthDebuggerWrapper />
|
||||||
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||||
<p className="text-lg text-gray-500">
|
<p className="text-lg text-gray-500">
|
||||||
Connect to an MCP server to start inspecting
|
Connect to an MCP server to start inspecting
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Need to configure authentication?
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsAuthDebuggerVisible(true)}
|
||||||
|
>
|
||||||
|
Open Auth Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
260
client/src/components/AuthDebugger.tsx
Normal file
260
client/src/components/AuthDebugger.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DebugInspectorOAuthClientProvider } from "../lib/auth";
|
||||||
|
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import { AuthDebuggerState } from "../lib/auth-types";
|
||||||
|
import { OAuthFlowProgress } from "./OAuthFlowProgress";
|
||||||
|
import { OAuthStateMachine } from "../lib/oauth-state-machine";
|
||||||
|
|
||||||
|
export interface AuthDebuggerProps {
|
||||||
|
serverUrl: string;
|
||||||
|
onBack: () => void;
|
||||||
|
authState: AuthDebuggerState;
|
||||||
|
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusMessageProps {
|
||||||
|
message: { type: "error" | "success" | "info"; message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusMessage = ({ message }: StatusMessageProps) => {
|
||||||
|
let bgColor: string;
|
||||||
|
let textColor: string;
|
||||||
|
let borderColor: string;
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case "error":
|
||||||
|
bgColor = "bg-red-50";
|
||||||
|
textColor = "text-red-700";
|
||||||
|
borderColor = "border-red-200";
|
||||||
|
break;
|
||||||
|
case "success":
|
||||||
|
bgColor = "bg-green-50";
|
||||||
|
textColor = "text-green-700";
|
||||||
|
borderColor = "border-green-200";
|
||||||
|
break;
|
||||||
|
case "info":
|
||||||
|
default:
|
||||||
|
bgColor = "bg-blue-50";
|
||||||
|
textColor = "text-blue-700";
|
||||||
|
borderColor = "border-blue-200";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-md border ${bgColor} ${borderColor} ${textColor} mb-4`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<p className="text-sm">{message.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthDebugger = ({
|
||||||
|
serverUrl: serverUrl,
|
||||||
|
onBack,
|
||||||
|
authState,
|
||||||
|
updateAuthState,
|
||||||
|
}: AuthDebuggerProps) => {
|
||||||
|
const startOAuthFlow = useCallback(() => {
|
||||||
|
if (!serverUrl) {
|
||||||
|
updateAuthState({
|
||||||
|
statusMessage: {
|
||||||
|
type: "error",
|
||||||
|
message:
|
||||||
|
"Please enter a server URL in the sidebar before authenticating",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAuthState({
|
||||||
|
oauthStep: "metadata_discovery",
|
||||||
|
authorizationUrl: null,
|
||||||
|
statusMessage: null,
|
||||||
|
latestError: null,
|
||||||
|
});
|
||||||
|
}, [serverUrl, updateAuthState]);
|
||||||
|
|
||||||
|
const stateMachine = useMemo(
|
||||||
|
() => new OAuthStateMachine(serverUrl, updateAuthState),
|
||||||
|
[serverUrl, updateAuthState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const proceedToNextStep = useCallback(async () => {
|
||||||
|
if (!serverUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateAuthState({
|
||||||
|
isInitiatingAuth: true,
|
||||||
|
statusMessage: null,
|
||||||
|
latestError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await stateMachine.executeStep(authState);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("OAuth flow error:", error);
|
||||||
|
updateAuthState({
|
||||||
|
latestError: error instanceof Error ? error : new Error(String(error)),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
updateAuthState({ isInitiatingAuth: false });
|
||||||
|
}
|
||||||
|
}, [serverUrl, authState, updateAuthState, stateMachine]);
|
||||||
|
|
||||||
|
const handleQuickOAuth = useCallback(async () => {
|
||||||
|
if (!serverUrl) {
|
||||||
|
updateAuthState({
|
||||||
|
statusMessage: {
|
||||||
|
type: "error",
|
||||||
|
message:
|
||||||
|
"Please enter a server URL in the sidebar before authenticating",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAuthState({ isInitiatingAuth: true, statusMessage: null });
|
||||||
|
try {
|
||||||
|
const serverAuthProvider = new DebugInspectorOAuthClientProvider(
|
||||||
|
serverUrl,
|
||||||
|
);
|
||||||
|
await auth(serverAuthProvider, { serverUrl: serverUrl });
|
||||||
|
updateAuthState({
|
||||||
|
statusMessage: {
|
||||||
|
type: "info",
|
||||||
|
message: "Starting OAuth authentication process...",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("OAuth initialization error:", error);
|
||||||
|
updateAuthState({
|
||||||
|
statusMessage: {
|
||||||
|
type: "error",
|
||||||
|
message: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
updateAuthState({ isInitiatingAuth: false });
|
||||||
|
}
|
||||||
|
}, [serverUrl, updateAuthState]);
|
||||||
|
|
||||||
|
const handleClearOAuth = useCallback(() => {
|
||||||
|
if (serverUrl) {
|
||||||
|
const serverAuthProvider = new DebugInspectorOAuthClientProvider(
|
||||||
|
serverUrl,
|
||||||
|
);
|
||||||
|
serverAuthProvider.clear();
|
||||||
|
updateAuthState({
|
||||||
|
oauthTokens: null,
|
||||||
|
oauthStep: "metadata_discovery",
|
||||||
|
latestError: null,
|
||||||
|
oauthClientInfo: null,
|
||||||
|
authorizationCode: "",
|
||||||
|
validationError: null,
|
||||||
|
oauthMetadata: null,
|
||||||
|
statusMessage: {
|
||||||
|
type: "success",
|
||||||
|
message: "OAuth tokens cleared successfully",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear success message after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
updateAuthState({ statusMessage: null });
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}, [serverUrl, updateAuthState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full p-4">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-2xl font-bold">Authentication Settings</h2>
|
||||||
|
<Button variant="outline" onClick={onBack}>
|
||||||
|
Back to Connect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="grid w-full gap-2">
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Configure authentication settings for your MCP server connection.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="rounded-md border p-6 space-y-6">
|
||||||
|
<h3 className="text-lg font-medium">OAuth Authentication</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Use OAuth to securely authenticate with the MCP server.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{authState.statusMessage && (
|
||||||
|
<StatusMessage message={authState.statusMessage} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authState.loading ? (
|
||||||
|
<p>Loading authentication status...</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{authState.oauthTokens && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">Access Token:</p>
|
||||||
|
<div className="bg-muted p-2 rounded-md text-xs overflow-x-auto">
|
||||||
|
{authState.oauthTokens.access_token.substring(0, 25)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={startOAuthFlow}
|
||||||
|
disabled={authState.isInitiatingAuth}
|
||||||
|
>
|
||||||
|
{authState.oauthTokens
|
||||||
|
? "Guided Token Refresh"
|
||||||
|
: "Guided OAuth Flow"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleQuickOAuth}
|
||||||
|
disabled={authState.isInitiatingAuth}
|
||||||
|
>
|
||||||
|
{authState.isInitiatingAuth
|
||||||
|
? "Initiating..."
|
||||||
|
: authState.oauthTokens
|
||||||
|
? "Quick Refresh"
|
||||||
|
: "Quick OAuth Flow"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={handleClearOAuth}>
|
||||||
|
Clear OAuth State
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Choose "Guided" for step-by-step instructions or "Quick" for
|
||||||
|
the standard automatic flow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OAuthFlowProgress
|
||||||
|
serverUrl={serverUrl}
|
||||||
|
authState={authState}
|
||||||
|
updateAuthState={updateAuthState}
|
||||||
|
proceedToNextStep={proceedToNextStep}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthDebugger;
|
||||||
@@ -1,35 +1,10 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import JsonEditor from "./JsonEditor";
|
import JsonEditor from "./JsonEditor";
|
||||||
import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils";
|
import { updateValueAtPath } from "@/utils/jsonUtils";
|
||||||
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
|
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||||
|
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
export type JsonValue =
|
|
||||||
| string
|
|
||||||
| number
|
|
||||||
| boolean
|
|
||||||
| null
|
|
||||||
| undefined
|
|
||||||
| JsonValue[]
|
|
||||||
| { [key: string]: JsonValue };
|
|
||||||
|
|
||||||
export type JsonSchemaType = {
|
|
||||||
type:
|
|
||||||
| "string"
|
|
||||||
| "number"
|
|
||||||
| "integer"
|
|
||||||
| "boolean"
|
|
||||||
| "array"
|
|
||||||
| "object"
|
|
||||||
| "null";
|
|
||||||
description?: string;
|
|
||||||
required?: boolean;
|
|
||||||
default?: JsonValue;
|
|
||||||
properties?: Record<string, JsonSchemaType>;
|
|
||||||
items?: JsonSchemaType;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DynamicJsonFormProps {
|
interface DynamicJsonFormProps {
|
||||||
schema: JsonSchemaType;
|
schema: JsonSchemaType;
|
||||||
@@ -38,13 +13,23 @@ interface DynamicJsonFormProps {
|
|||||||
maxDepth?: number;
|
maxDepth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSimpleObject = (schema: JsonSchemaType): boolean => {
|
||||||
|
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
|
||||||
|
if (supportedTypes.includes(schema.type)) return true;
|
||||||
|
if (schema.type !== "object") return false;
|
||||||
|
return Object.values(schema.properties ?? {}).every((prop) =>
|
||||||
|
supportedTypes.includes(prop.type),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const DynamicJsonForm = ({
|
const DynamicJsonForm = ({
|
||||||
schema,
|
schema,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
maxDepth = 3,
|
maxDepth = 3,
|
||||||
}: DynamicJsonFormProps) => {
|
}: DynamicJsonFormProps) => {
|
||||||
const [isJsonMode, setIsJsonMode] = useState(false);
|
const isOnlyJSON = !isSimpleObject(schema);
|
||||||
|
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
|
||||||
const [jsonError, setJsonError] = useState<string>();
|
const [jsonError, setJsonError] = useState<string>();
|
||||||
// Store the raw JSON string to allow immediate feedback during typing
|
// Store the raw JSON string to allow immediate feedback during typing
|
||||||
// while deferring parsing until the user stops typing
|
// while deferring parsing until the user stops typing
|
||||||
@@ -231,111 +216,6 @@ const DynamicJsonForm = ({
|
|||||||
required={propSchema.required}
|
required={propSchema.required}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "object": {
|
|
||||||
// Handle case where we have a value but no schema properties
|
|
||||||
const objectValue = (currentValue as JsonObject) || {};
|
|
||||||
|
|
||||||
// If we have schema properties, use them to render fields
|
|
||||||
if (propSchema.properties) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 border rounded-md p-4">
|
|
||||||
{Object.entries(propSchema.properties).map(([key, prop]) => (
|
|
||||||
<div key={key} className="space-y-2">
|
|
||||||
<Label>{formatFieldLabel(key)}</Label>
|
|
||||||
{renderFormFields(
|
|
||||||
prop,
|
|
||||||
objectValue[key],
|
|
||||||
[...path, key],
|
|
||||||
depth + 1,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// If we have a value but no schema properties, render fields based on the value
|
|
||||||
else if (Object.keys(objectValue).length > 0) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 border rounded-md p-4">
|
|
||||||
{Object.entries(objectValue).map(([key, value]) => (
|
|
||||||
<div key={key} className="space-y-2">
|
|
||||||
<Label>{formatFieldLabel(key)}</Label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={String(value)}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleFieldChange([...path, key], e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// If we have neither schema properties nor value, return null
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
case "array": {
|
|
||||||
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
|
|
||||||
if (!propSchema.items) return null;
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{propSchema.description && (
|
|
||||||
<p className="text-sm text-gray-600">{propSchema.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{propSchema.items?.description && (
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Items: {propSchema.items.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{arrayValue.map((item, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-2">
|
|
||||||
{renderFormFields(
|
|
||||||
propSchema.items as JsonSchemaType,
|
|
||||||
item,
|
|
||||||
[...path, index.toString()],
|
|
||||||
depth + 1,
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const newArray = [...arrayValue];
|
|
||||||
newArray.splice(index, 1);
|
|
||||||
handleFieldChange(path, newArray);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const defaultValue = generateDefaultValue(
|
|
||||||
propSchema.items as JsonSchemaType,
|
|
||||||
);
|
|
||||||
handleFieldChange(path, [
|
|
||||||
...arrayValue,
|
|
||||||
defaultValue ?? null,
|
|
||||||
]);
|
|
||||||
}}
|
|
||||||
title={
|
|
||||||
propSchema.items?.description
|
|
||||||
? `Add new ${propSchema.items.description}`
|
|
||||||
: "Add new item"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Add Item
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -370,13 +250,20 @@ const DynamicJsonForm = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end space-x-2">
|
||||||
{isJsonMode && (
|
{isJsonMode && (
|
||||||
<Button variant="outline" size="sm" onClick={formatJson}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={formatJson}
|
||||||
|
>
|
||||||
Format JSON
|
Format JSON
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{!isOnlyJSON && (
|
||||||
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
|
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
|
||||||
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
|
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isJsonMode ? (
|
{isJsonMode ? (
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, memo, useMemo, useCallback, useEffect } from "react";
|
import { useState, memo, useMemo, useCallback, useEffect } from "react";
|
||||||
import { JsonValue } from "./DynamicJsonForm";
|
import type { JsonValue } from "@/utils/jsonUtils";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Copy, CheckCheck } from "lucide-react";
|
import { Copy, CheckCheck } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/lib/hooks/useToast";
|
||||||
|
import { getDataType, tryParseJson } from "@/utils/jsonUtils";
|
||||||
|
|
||||||
interface JsonViewProps {
|
interface JsonViewProps {
|
||||||
data: unknown;
|
data: unknown;
|
||||||
@@ -11,21 +12,7 @@ interface JsonViewProps {
|
|||||||
initialExpandDepth?: number;
|
initialExpandDepth?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
withCopyButton?: boolean;
|
withCopyButton?: boolean;
|
||||||
}
|
isError?: boolean;
|
||||||
|
|
||||||
function tryParseJson(str: string): { success: boolean; data: JsonValue } {
|
|
||||||
const trimmed = str.trim();
|
|
||||||
if (
|
|
||||||
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
|
|
||||||
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
|
||||||
) {
|
|
||||||
return { success: false, data: str };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return { success: true, data: JSON.parse(str) };
|
|
||||||
} catch {
|
|
||||||
return { success: false, data: str };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const JsonView = memo(
|
const JsonView = memo(
|
||||||
@@ -35,6 +22,7 @@ const JsonView = memo(
|
|||||||
initialExpandDepth = 3,
|
initialExpandDepth = 3,
|
||||||
className,
|
className,
|
||||||
withCopyButton = true,
|
withCopyButton = true,
|
||||||
|
isError = false,
|
||||||
}: JsonViewProps) => {
|
}: JsonViewProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -100,6 +88,7 @@ const JsonView = memo(
|
|||||||
name={name}
|
name={name}
|
||||||
depth={0}
|
depth={0}
|
||||||
initialExpandDepth={initialExpandDepth}
|
initialExpandDepth={initialExpandDepth}
|
||||||
|
isError={isError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,28 +103,28 @@ interface JsonNodeProps {
|
|||||||
name?: string;
|
name?: string;
|
||||||
depth: number;
|
depth: number;
|
||||||
initialExpandDepth: number;
|
initialExpandDepth: number;
|
||||||
|
isError?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JsonNode = memo(
|
const JsonNode = memo(
|
||||||
({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => {
|
({
|
||||||
|
data,
|
||||||
|
name,
|
||||||
|
depth = 0,
|
||||||
|
initialExpandDepth,
|
||||||
|
isError = false,
|
||||||
|
}: JsonNodeProps) => {
|
||||||
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
|
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
|
||||||
|
const [typeStyleMap] = useState<Record<string, string>>({
|
||||||
const getDataType = (value: JsonValue): string => {
|
|
||||||
if (Array.isArray(value)) return "array";
|
|
||||||
if (value === null) return "null";
|
|
||||||
return typeof value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dataType = getDataType(data);
|
|
||||||
|
|
||||||
const typeStyleMap: Record<string, string> = {
|
|
||||||
number: "text-blue-600",
|
number: "text-blue-600",
|
||||||
boolean: "text-amber-600",
|
boolean: "text-amber-600",
|
||||||
null: "text-purple-600",
|
null: "text-purple-600",
|
||||||
undefined: "text-gray-600",
|
undefined: "text-gray-600",
|
||||||
string: "text-green-600 break-all whitespace-pre-wrap",
|
string: "text-green-600 group-hover:text-green-500",
|
||||||
|
error: "text-red-600 group-hover:text-red-500",
|
||||||
default: "text-gray-700",
|
default: "text-gray-700",
|
||||||
};
|
});
|
||||||
|
const dataType = getDataType(data);
|
||||||
|
|
||||||
const renderCollapsible = (isArray: boolean) => {
|
const renderCollapsible = (isArray: boolean) => {
|
||||||
const items = isArray
|
const items = isArray
|
||||||
@@ -236,7 +225,14 @@ const JsonNode = memo(
|
|||||||
{name}:
|
{name}:
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<pre className={typeStyleMap.string}>"{value}"</pre>
|
<pre
|
||||||
|
className={clsx(
|
||||||
|
isError ? typeStyleMap.error : typeStyleMap.string,
|
||||||
|
"break-all whitespace-pre-wrap",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
"{value}"
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -250,8 +246,8 @@ const JsonNode = memo(
|
|||||||
)}
|
)}
|
||||||
<pre
|
<pre
|
||||||
className={clsx(
|
className={clsx(
|
||||||
typeStyleMap.string,
|
isError ? typeStyleMap.error : typeStyleMap.string,
|
||||||
"cursor-pointer group-hover:text-green-500",
|
"cursor-pointer break-all whitespace-pre-wrap",
|
||||||
)}
|
)}
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
title={isExpanded ? "Click to collapse" : "Click to expand"}
|
title={isExpanded ? "Click to collapse" : "Click to expand"}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const ListPane = <T extends object>({
|
|||||||
isButtonDisabled,
|
isButtonDisabled,
|
||||||
}: ListPaneProps<T>) => (
|
}: ListPaneProps<T>) => (
|
||||||
<div className="bg-card rounded-lg shadow">
|
<div className="bg-card rounded-lg shadow">
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||||
<h3 className="font-semibold dark:text-white">{title}</h3>
|
<h3 className="font-semibold dark:text-white">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { authProvider } from "../lib/auth";
|
import { InspectorOAuthClientProvider } from "../lib/auth";
|
||||||
import { SESSION_KEYS } from "../lib/constants";
|
import { SESSION_KEYS } from "../lib/constants";
|
||||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
|
import { useToast } from "@/lib/hooks/useToast";
|
||||||
|
import {
|
||||||
|
generateOAuthErrorDescription,
|
||||||
|
parseOAuthCallbackParams,
|
||||||
|
} from "@/utils/oauthUtils.ts";
|
||||||
|
|
||||||
const OAuthCallback = () => {
|
interface OAuthCallbackProps {
|
||||||
|
onConnect: (serverUrl: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OAuthCallback = ({ onConnect }: OAuthCallbackProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
const hasProcessedRef = useRef(false);
|
const hasProcessedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -14,37 +24,56 @@ const OAuthCallback = () => {
|
|||||||
}
|
}
|
||||||
hasProcessedRef.current = true;
|
hasProcessedRef.current = true;
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const notifyError = (description: string) =>
|
||||||
const code = params.get("code");
|
void toast({
|
||||||
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
|
title: "OAuth Authorization Error",
|
||||||
|
description,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
|
||||||
if (!code || !serverUrl) {
|
const params = parseOAuthCallbackParams(window.location.search);
|
||||||
console.error("Missing code or server URL");
|
if (!params.successful) {
|
||||||
window.location.href = "/";
|
return notifyError(generateOAuthErrorDescription(params));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
|
||||||
|
if (!serverUrl) {
|
||||||
|
return notifyError("Missing Server URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
try {
|
try {
|
||||||
const result = await auth(authProvider, {
|
// Create an auth provider with the current server URL
|
||||||
|
const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl);
|
||||||
|
|
||||||
|
result = await auth(serverAuthProvider, {
|
||||||
serverUrl,
|
serverUrl,
|
||||||
authorizationCode: code,
|
authorizationCode: params.code,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("OAuth callback error:", error);
|
||||||
|
return notifyError(`Unexpected error occurred: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (result !== "AUTHORIZED") {
|
if (result !== "AUTHORIZED") {
|
||||||
throw new Error(
|
return notifyError(
|
||||||
`Expected to be authorized after providing auth code, got: ${result}`,
|
`Expected to be authorized after providing auth code, got: ${result}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect back to the main app with server URL to trigger auto-connect
|
// Finally, trigger auto-connect
|
||||||
window.location.href = `/?serverUrl=${encodeURIComponent(serverUrl)}`;
|
toast({
|
||||||
} catch (error) {
|
title: "Success",
|
||||||
console.error("OAuth callback error:", error);
|
description: "Successfully authenticated with OAuth",
|
||||||
window.location.href = "/";
|
variant: "default",
|
||||||
}
|
});
|
||||||
|
onConnect(serverUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
void handleCallback();
|
handleCallback().finally(() => {
|
||||||
}, []);
|
window.history.replaceState({}, document.title, "/");
|
||||||
|
});
|
||||||
|
}, [toast, onConnect]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen">
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
|||||||
92
client/src/components/OAuthDebugCallback.tsx
Normal file
92
client/src/components/OAuthDebugCallback.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { SESSION_KEYS } from "../lib/constants";
|
||||||
|
import {
|
||||||
|
generateOAuthErrorDescription,
|
||||||
|
parseOAuthCallbackParams,
|
||||||
|
} from "@/utils/oauthUtils.ts";
|
||||||
|
|
||||||
|
interface OAuthCallbackProps {
|
||||||
|
onConnect: ({
|
||||||
|
authorizationCode,
|
||||||
|
errorMsg,
|
||||||
|
}: {
|
||||||
|
authorizationCode?: string;
|
||||||
|
errorMsg?: string;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => {
|
||||||
|
useEffect(() => {
|
||||||
|
let isProcessed = false;
|
||||||
|
|
||||||
|
const handleCallback = async () => {
|
||||||
|
// Skip if we've already processed this callback
|
||||||
|
if (isProcessed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isProcessed = true;
|
||||||
|
|
||||||
|
const params = parseOAuthCallbackParams(window.location.search);
|
||||||
|
if (!params.successful) {
|
||||||
|
const errorMsg = generateOAuthErrorDescription(params);
|
||||||
|
onConnect({ errorMsg });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
|
||||||
|
|
||||||
|
// ServerURL isn't set, this can happen if we've opened the
|
||||||
|
// authentication request in a new tab, so we don't have the same
|
||||||
|
// session storage
|
||||||
|
if (!serverUrl) {
|
||||||
|
// If there's no server URL, we're likely in a new tab
|
||||||
|
// Just display the code for manual copying
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params.code) {
|
||||||
|
onConnect({ errorMsg: "Missing authorization code" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instead of storing in sessionStorage, pass the code directly
|
||||||
|
// to the auth state manager through onConnect
|
||||||
|
onConnect({ authorizationCode: params.code });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCallback().finally(() => {
|
||||||
|
// Only redirect if we have the URL set, otherwise assume this was
|
||||||
|
// in a new tab
|
||||||
|
if (sessionStorage.getItem(SESSION_KEYS.SERVER_URL)) {
|
||||||
|
window.history.replaceState({}, document.title, "/");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isProcessed = true;
|
||||||
|
};
|
||||||
|
}, [onConnect]);
|
||||||
|
|
||||||
|
const callbackParams = parseOAuthCallbackParams(window.location.search);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<div className="mt-4 p-4 bg-secondary rounded-md max-w-md">
|
||||||
|
<p className="mb-2 text-sm">
|
||||||
|
Please copy this authorization code and return to the Auth Debugger:
|
||||||
|
</p>
|
||||||
|
<code className="block p-2 bg-muted rounded-sm overflow-x-auto text-xs">
|
||||||
|
{callbackParams.successful && "code" in callbackParams
|
||||||
|
? callbackParams.code
|
||||||
|
: `No code found: ${callbackParams.error}, ${callbackParams.error_description}`}
|
||||||
|
</code>
|
||||||
|
<p className="mt-4 text-xs text-muted-foreground">
|
||||||
|
Close this tab and paste the code in the OAuth flow to complete
|
||||||
|
authentication.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OAuthDebugCallback;
|
||||||
259
client/src/components/OAuthFlowProgress.tsx
Normal file
259
client/src/components/OAuthFlowProgress.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types";
|
||||||
|
import { CheckCircle2, Circle, ExternalLink } from "lucide-react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
|
||||||
|
|
||||||
|
interface OAuthStepProps {
|
||||||
|
label: string;
|
||||||
|
isComplete: boolean;
|
||||||
|
isCurrent: boolean;
|
||||||
|
error?: Error | null;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OAuthStepDetails = ({
|
||||||
|
label,
|
||||||
|
isComplete,
|
||||||
|
isCurrent,
|
||||||
|
error,
|
||||||
|
children,
|
||||||
|
}: OAuthStepProps) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={`flex items-center p-2 rounded-md ${isCurrent ? "bg-accent" : ""}`}
|
||||||
|
>
|
||||||
|
{isComplete ? (
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-green-500 mr-2" />
|
||||||
|
) : (
|
||||||
|
<Circle className="h-5 w-5 text-muted-foreground mr-2" />
|
||||||
|
)}
|
||||||
|
<span className={`${isCurrent ? "font-medium" : ""}`}>{label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show children if current step or complete and children exist */}
|
||||||
|
{(isCurrent || isComplete) && children && (
|
||||||
|
<div className="ml-7 mt-1">{children}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Display error if current step and an error exists */}
|
||||||
|
{isCurrent && error && (
|
||||||
|
<div className="ml-7 mt-2 p-3 border border-red-300 bg-red-50 rounded-md">
|
||||||
|
<p className="text-sm font-medium text-red-700">Error:</p>
|
||||||
|
<p className="text-xs text-red-600 mt-1">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OAuthFlowProgressProps {
|
||||||
|
serverUrl: string;
|
||||||
|
authState: AuthDebuggerState;
|
||||||
|
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
|
||||||
|
proceedToNextStep: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OAuthFlowProgress = ({
|
||||||
|
serverUrl,
|
||||||
|
authState,
|
||||||
|
updateAuthState,
|
||||||
|
proceedToNextStep,
|
||||||
|
}: OAuthFlowProgressProps) => {
|
||||||
|
const provider = new DebugInspectorOAuthClientProvider(serverUrl);
|
||||||
|
|
||||||
|
const steps: Array<OAuthStep> = [
|
||||||
|
"metadata_discovery",
|
||||||
|
"client_registration",
|
||||||
|
"authorization_redirect",
|
||||||
|
"authorization_code",
|
||||||
|
"token_request",
|
||||||
|
"complete",
|
||||||
|
];
|
||||||
|
const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep);
|
||||||
|
|
||||||
|
// Helper to get step props
|
||||||
|
const getStepProps = (stepName: OAuthStep) => ({
|
||||||
|
isComplete:
|
||||||
|
currentStepIdx > steps.indexOf(stepName) ||
|
||||||
|
currentStepIdx === steps.length - 1, // last step is "complete"
|
||||||
|
isCurrent: authState.oauthStep === stepName,
|
||||||
|
error: authState.oauthStep === stepName ? authState.latestError : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border p-6 space-y-4 mt-4">
|
||||||
|
<h3 className="text-lg font-medium">OAuth Flow Progress</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Follow these steps to complete OAuth authentication with the server.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<OAuthStepDetails
|
||||||
|
label="Metadata Discovery"
|
||||||
|
{...getStepProps("metadata_discovery")}
|
||||||
|
>
|
||||||
|
{provider.getServerMetadata() && (
|
||||||
|
<details className="text-xs mt-2">
|
||||||
|
<summary className="cursor-pointer text-muted-foreground font-medium">
|
||||||
|
Retrieved OAuth Metadata from {serverUrl}
|
||||||
|
/.well-known/oauth-authorization-server
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
|
||||||
|
{JSON.stringify(provider.getServerMetadata(), null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</OAuthStepDetails>
|
||||||
|
|
||||||
|
<OAuthStepDetails
|
||||||
|
label="Client Registration"
|
||||||
|
{...getStepProps("client_registration")}
|
||||||
|
>
|
||||||
|
{authState.oauthClientInfo && (
|
||||||
|
<details className="text-xs mt-2">
|
||||||
|
<summary className="cursor-pointer text-muted-foreground font-medium">
|
||||||
|
Registered Client Information
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
|
||||||
|
{JSON.stringify(authState.oauthClientInfo, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</OAuthStepDetails>
|
||||||
|
|
||||||
|
<OAuthStepDetails
|
||||||
|
label="Preparing Authorization"
|
||||||
|
{...getStepProps("authorization_redirect")}
|
||||||
|
>
|
||||||
|
{authState.authorizationUrl && (
|
||||||
|
<div className="mt-2 p-3 border rounded-md bg-muted">
|
||||||
|
<p className="font-medium mb-2 text-sm">Authorization URL:</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-xs break-all">
|
||||||
|
{authState.authorizationUrl}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={authState.authorizationUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center text-blue-500 hover:text-blue-700"
|
||||||
|
aria-label="Open authorization URL in new tab"
|
||||||
|
title="Open authorization URL"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Click the link to authorize in your browser. After
|
||||||
|
authorization, you'll be redirected back to continue the flow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</OAuthStepDetails>
|
||||||
|
|
||||||
|
<OAuthStepDetails
|
||||||
|
label="Request Authorization and acquire authorization code"
|
||||||
|
{...getStepProps("authorization_code")}
|
||||||
|
>
|
||||||
|
<div className="mt-3">
|
||||||
|
<label
|
||||||
|
htmlFor="authCode"
|
||||||
|
className="block text-sm font-medium mb-1"
|
||||||
|
>
|
||||||
|
Authorization Code
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
id="authCode"
|
||||||
|
value={authState.authorizationCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateAuthState({
|
||||||
|
authorizationCode: e.target.value,
|
||||||
|
validationError: null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="Enter the code from the authorization server"
|
||||||
|
className={`flex h-9 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
|
||||||
|
authState.validationError ? "border-red-500" : "border-input"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{authState.validationError && (
|
||||||
|
<p className="text-xs text-red-600 mt-1">
|
||||||
|
{authState.validationError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Once you've completed authorization in the link, paste the code
|
||||||
|
here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</OAuthStepDetails>
|
||||||
|
|
||||||
|
<OAuthStepDetails
|
||||||
|
label="Token Request"
|
||||||
|
{...getStepProps("token_request")}
|
||||||
|
>
|
||||||
|
{authState.oauthMetadata && (
|
||||||
|
<details className="text-xs mt-2">
|
||||||
|
<summary className="cursor-pointer text-muted-foreground font-medium">
|
||||||
|
Token Request Details
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 p-2 bg-muted rounded-md">
|
||||||
|
<p className="font-medium">Token Endpoint:</p>
|
||||||
|
<code className="block mt-1 text-xs overflow-x-auto">
|
||||||
|
{authState.oauthMetadata.token_endpoint}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</OAuthStepDetails>
|
||||||
|
|
||||||
|
<OAuthStepDetails
|
||||||
|
label="Authentication Complete"
|
||||||
|
{...getStepProps("complete")}
|
||||||
|
>
|
||||||
|
{authState.oauthTokens && (
|
||||||
|
<details className="text-xs mt-2">
|
||||||
|
<summary className="cursor-pointer text-muted-foreground font-medium">
|
||||||
|
Access Tokens
|
||||||
|
</summary>
|
||||||
|
<p className="mt-1 text-sm">
|
||||||
|
Authentication successful! You can now use the authenticated
|
||||||
|
connection. These tokens will be used automatically for server
|
||||||
|
requests.
|
||||||
|
</p>
|
||||||
|
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
|
||||||
|
{JSON.stringify(authState.oauthTokens, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</OAuthStepDetails>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-4">
|
||||||
|
{authState.oauthStep !== "complete" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={proceedToNextStep}
|
||||||
|
disabled={authState.isInitiatingAuth}
|
||||||
|
>
|
||||||
|
{authState.isInitiatingAuth ? "Processing..." : "Continue"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authState.oauthStep === "authorization_redirect" &&
|
||||||
|
authState.authorizationUrl && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => window.open(authState.authorizationUrl!, "_blank")}
|
||||||
|
>
|
||||||
|
Open in New Tab
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,7 +3,8 @@ import { Button } from "@/components/ui/button";
|
|||||||
|
|
||||||
const PingTab = ({ onPingClick }: { onPingClick: () => void }) => {
|
const PingTab = ({ onPingClick }: { onPingClick: () => void }) => {
|
||||||
return (
|
return (
|
||||||
<TabsContent value="ping" className="grid grid-cols-2 gap-4">
|
<TabsContent value="ping">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="col-span-2 flex justify-center items-center">
|
<div className="col-span-2 flex justify-center items-center">
|
||||||
<Button
|
<Button
|
||||||
onClick={onPingClick}
|
onClick={onPingClick}
|
||||||
@@ -12,6 +13,7 @@ const PingTab = ({ onPingClick }: { onPingClick: () => void }) => {
|
|||||||
Ping Server
|
Ping Server
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const PromptsTab = ({
|
|||||||
clearPrompts: () => void;
|
clearPrompts: () => void;
|
||||||
getPrompt: (name: string, args: Record<string, string>) => void;
|
getPrompt: (name: string, args: Record<string, string>) => void;
|
||||||
selectedPrompt: Prompt | null;
|
selectedPrompt: Prompt | null;
|
||||||
setSelectedPrompt: (prompt: Prompt) => void;
|
setSelectedPrompt: (prompt: Prompt | null) => void;
|
||||||
handleCompletion: (
|
handleCompletion: (
|
||||||
ref: PromptReference | ResourceReference,
|
ref: PromptReference | ResourceReference,
|
||||||
argName: string,
|
argName: string,
|
||||||
@@ -84,11 +84,15 @@ const PromptsTab = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent value="prompts" className="grid grid-cols-2 gap-4">
|
<TabsContent value="prompts">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<ListPane
|
<ListPane
|
||||||
items={prompts}
|
items={prompts}
|
||||||
listItems={listPrompts}
|
listItems={listPrompts}
|
||||||
clearItems={clearPrompts}
|
clearItems={() => {
|
||||||
|
clearPrompts();
|
||||||
|
setSelectedPrompt(null);
|
||||||
|
}}
|
||||||
setSelectedItem={(prompt) => {
|
setSelectedItem={(prompt) => {
|
||||||
setSelectedPrompt(prompt);
|
setSelectedPrompt(prompt);
|
||||||
setPromptArgs({});
|
setPromptArgs({});
|
||||||
@@ -96,7 +100,9 @@ const PromptsTab = ({
|
|||||||
renderItem={(prompt) => (
|
renderItem={(prompt) => (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1">{prompt.name}</span>
|
<span className="flex-1">{prompt.name}</span>
|
||||||
<span className="text-sm text-gray-500">{prompt.description}</span>
|
<span className="text-sm text-gray-500">
|
||||||
|
{prompt.description}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
title="Prompts"
|
title="Prompts"
|
||||||
@@ -105,7 +111,7 @@ const PromptsTab = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="bg-card rounded-lg shadow">
|
<div className="bg-card rounded-lg shadow">
|
||||||
<div className="p-4 border-b border-gray-200">
|
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||||
<h3 className="font-semibold">
|
<h3 className="font-semibold">
|
||||||
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
|
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -164,6 +170,7 @@ const PromptsTab = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -104,18 +104,24 @@ const ResourcesTab = ({
|
|||||||
if (selectedTemplate) {
|
if (selectedTemplate) {
|
||||||
const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues);
|
const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues);
|
||||||
readResource(uri);
|
readResource(uri);
|
||||||
setSelectedTemplate(null);
|
|
||||||
// We don't have the full Resource object here, so we create a partial one
|
// We don't have the full Resource object here, so we create a partial one
|
||||||
setSelectedResource({ uri, name: uri } as Resource);
|
setSelectedResource({ uri, name: uri } as Resource);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent value="resources" className="grid grid-cols-3 gap-4">
|
<TabsContent value="resources">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<ListPane
|
<ListPane
|
||||||
items={resources}
|
items={resources}
|
||||||
listItems={listResources}
|
listItems={listResources}
|
||||||
clearItems={clearResources}
|
clearItems={() => {
|
||||||
|
clearResources();
|
||||||
|
// Condition to check if selected resource is not resource template's resource
|
||||||
|
if (!selectedTemplate) {
|
||||||
|
setSelectedResource(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
setSelectedItem={(resource) => {
|
setSelectedItem={(resource) => {
|
||||||
setSelectedResource(resource);
|
setSelectedResource(resource);
|
||||||
readResource(resource.uri);
|
readResource(resource.uri);
|
||||||
@@ -138,7 +144,14 @@ const ResourcesTab = ({
|
|||||||
<ListPane
|
<ListPane
|
||||||
items={resourceTemplates}
|
items={resourceTemplates}
|
||||||
listItems={listResourceTemplates}
|
listItems={listResourceTemplates}
|
||||||
clearItems={clearResourceTemplates}
|
clearItems={() => {
|
||||||
|
clearResourceTemplates();
|
||||||
|
// Condition to check if selected resource is resource template's resource
|
||||||
|
if (selectedTemplate) {
|
||||||
|
setSelectedResource(null);
|
||||||
|
}
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
}}
|
||||||
setSelectedItem={(template) => {
|
setSelectedItem={(template) => {
|
||||||
setSelectedTemplate(template);
|
setSelectedTemplate(template);
|
||||||
setSelectedResource(null);
|
setSelectedResource(null);
|
||||||
@@ -161,7 +174,7 @@ const ResourcesTab = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="bg-card rounded-lg shadow">
|
<div className="bg-card rounded-lg shadow">
|
||||||
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
<div className="p-4 border-b border-gray-200 dark:border-gray-800 flex justify-between items-center">
|
||||||
<h3
|
<h3
|
||||||
className="font-semibold truncate"
|
className="font-semibold truncate"
|
||||||
title={selectedResource?.name || selectedTemplate?.name}
|
title={selectedResource?.name || selectedTemplate?.name}
|
||||||
@@ -256,12 +269,14 @@ const ResourcesTab = ({
|
|||||||
) : (
|
) : (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Select a resource or template from the list to view its contents
|
Select a resource or template from the list to view its
|
||||||
|
contents
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ const RootsTab = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent value="roots" className="space-y-4">
|
<TabsContent value="roots">
|
||||||
|
<div className="space-y-4">
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Configure the root directories that the server can access
|
Configure the root directories that the server can access
|
||||||
@@ -70,6 +71,7 @@ const RootsTab = ({
|
|||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
167
client/src/components/SamplingRequest.tsx
Normal file
167
client/src/components/SamplingRequest.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import JsonView from "./JsonView";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
CreateMessageResult,
|
||||||
|
CreateMessageResultSchema,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { PendingRequest } from "./SamplingTab";
|
||||||
|
import DynamicJsonForm from "./DynamicJsonForm";
|
||||||
|
import { useToast } from "@/lib/hooks/useToast";
|
||||||
|
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
|
||||||
|
|
||||||
|
export type SamplingRequestProps = {
|
||||||
|
request: PendingRequest;
|
||||||
|
onApprove: (id: number, result: CreateMessageResult) => void;
|
||||||
|
onReject: (id: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SamplingRequest = ({
|
||||||
|
onApprove,
|
||||||
|
request,
|
||||||
|
onReject,
|
||||||
|
}: SamplingRequestProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [messageResult, setMessageResult] = useState<JsonValue>({
|
||||||
|
model: "stub-model",
|
||||||
|
stopReason: "endTurn",
|
||||||
|
role: "assistant",
|
||||||
|
content: {
|
||||||
|
type: "text",
|
||||||
|
text: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = (
|
||||||
|
(messageResult as { [key: string]: JsonValue })?.content as {
|
||||||
|
[key: string]: JsonValue;
|
||||||
|
}
|
||||||
|
)?.type;
|
||||||
|
|
||||||
|
const schema = useMemo(() => {
|
||||||
|
const s: JsonSchemaType = {
|
||||||
|
type: "object",
|
||||||
|
description: "Message result",
|
||||||
|
properties: {
|
||||||
|
model: {
|
||||||
|
type: "string",
|
||||||
|
default: "stub-model",
|
||||||
|
description: "model name",
|
||||||
|
},
|
||||||
|
stopReason: {
|
||||||
|
type: "string",
|
||||||
|
default: "endTurn",
|
||||||
|
description: "Stop reason",
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
type: "string",
|
||||||
|
default: "endTurn",
|
||||||
|
description: "Role of the model",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
type: {
|
||||||
|
type: "string",
|
||||||
|
default: "text",
|
||||||
|
description: "Type of content",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (contentType === "text" && s.properties) {
|
||||||
|
s.properties.content.properties = {
|
||||||
|
...s.properties.content.properties,
|
||||||
|
text: {
|
||||||
|
type: "string",
|
||||||
|
default: "",
|
||||||
|
description: "text content",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setMessageResult((prev) => ({
|
||||||
|
...(prev as { [key: string]: JsonValue }),
|
||||||
|
content: {
|
||||||
|
type: contentType,
|
||||||
|
text: "",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} else if (contentType === "image" && s.properties) {
|
||||||
|
s.properties.content.properties = {
|
||||||
|
...s.properties.content.properties,
|
||||||
|
data: {
|
||||||
|
type: "string",
|
||||||
|
default: "",
|
||||||
|
description: "Base64 encoded image data",
|
||||||
|
},
|
||||||
|
mimeType: {
|
||||||
|
type: "string",
|
||||||
|
default: "",
|
||||||
|
description: "Mime type of the image",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setMessageResult((prev) => ({
|
||||||
|
...(prev as { [key: string]: JsonValue }),
|
||||||
|
content: {
|
||||||
|
type: contentType,
|
||||||
|
data: "",
|
||||||
|
mimeType: "",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}, [contentType]);
|
||||||
|
|
||||||
|
const handleApprove = (id: number) => {
|
||||||
|
const validationResult = CreateMessageResultSchema.safeParse(messageResult);
|
||||||
|
if (!validationResult.success) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: `There was an error validating the message result: ${validationResult.error.message}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onApprove(id, validationResult.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="sampling-request"
|
||||||
|
className="flex gap-4 p-4 border rounded-lg space-y-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
|
||||||
|
<JsonView data={JSON.stringify(request.request)} />
|
||||||
|
</div>
|
||||||
|
<form className="flex-1 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<DynamicJsonForm
|
||||||
|
schema={schema}
|
||||||
|
value={messageResult}
|
||||||
|
onChange={(newValue: JsonValue) => {
|
||||||
|
setMessageResult(newValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2 mt-1">
|
||||||
|
<Button type="button" onClick={() => handleApprove(request.id)}>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onReject(request.id)}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SamplingRequest;
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { TabsContent } from "@/components/ui/tabs";
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
CreateMessageRequest,
|
CreateMessageRequest,
|
||||||
CreateMessageResult,
|
CreateMessageResult,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import JsonView from "./JsonView";
|
import SamplingRequest from "./SamplingRequest";
|
||||||
|
|
||||||
export type PendingRequest = {
|
export type PendingRequest = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -19,21 +18,9 @@ export type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
|
const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
|
||||||
const handleApprove = (id: number) => {
|
|
||||||
// For now, just return a stub response
|
|
||||||
onApprove(id, {
|
|
||||||
model: "stub-model",
|
|
||||||
stopReason: "endTurn",
|
|
||||||
role: "assistant",
|
|
||||||
content: {
|
|
||||||
type: "text",
|
|
||||||
text: "This is a stub response.",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent value="sampling" className="h-96">
|
<TabsContent value="sampling">
|
||||||
|
<div className="h-96">
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
When the server requests LLM sampling, requests will appear here for
|
When the server requests LLM sampling, requests will appear here for
|
||||||
@@ -43,24 +30,18 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
|
|||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<h3 className="text-lg font-semibold">Recent Requests</h3>
|
<h3 className="text-lg font-semibold">Recent Requests</h3>
|
||||||
{pendingRequests.map((request) => (
|
{pendingRequests.map((request) => (
|
||||||
<div key={request.id} className="p-4 border rounded-lg space-y-4">
|
<SamplingRequest
|
||||||
<JsonView
|
key={request.id}
|
||||||
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
|
request={request}
|
||||||
data={JSON.stringify(request.request)}
|
onApprove={onApprove}
|
||||||
|
onReject={onReject}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button onClick={() => handleApprove(request.id)}>Approve</Button>
|
|
||||||
<Button variant="outline" onClick={() => onReject(request.id)}>
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
{pendingRequests.length === 0 && (
|
{pendingRequests.length === 0 && (
|
||||||
<p className="text-gray-500">No pending requests</p>
|
<p className="text-gray-500">No pending requests</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
RefreshCwOff,
|
RefreshCwOff,
|
||||||
|
Copy,
|
||||||
|
CheckCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -29,18 +31,20 @@ import {
|
|||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { InspectorConfig } from "@/lib/configurationTypes";
|
import { InspectorConfig } from "@/lib/configurationTypes";
|
||||||
import { ConnectionStatus } from "@/lib/constants";
|
import { ConnectionStatus } from "@/lib/constants";
|
||||||
import useTheme from "../lib/useTheme";
|
import useTheme from "../lib/hooks/useTheme";
|
||||||
import { version } from "../../../package.json";
|
import { version } from "../../../package.json";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { useToast } from "../lib/hooks/useToast";
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
|
||||||
interface SidebarProps {
|
|
||||||
connectionStatus: ConnectionStatus;
|
connectionStatus: ConnectionStatus;
|
||||||
transportType: "stdio" | "sse";
|
transportType: "stdio" | "sse" | "streamable-http";
|
||||||
setTransportType: (type: "stdio" | "sse") => void;
|
setTransportType: (type: "stdio" | "sse" | "streamable-http") => void;
|
||||||
command: string;
|
command: string;
|
||||||
setCommand: (command: string) => void;
|
setCommand: (command: string) => void;
|
||||||
args: string;
|
args: string;
|
||||||
@@ -51,9 +55,14 @@ interface SidebarProps {
|
|||||||
setEnv: (env: Record<string, string>) => void;
|
setEnv: (env: Record<string, string>) => void;
|
||||||
bearerToken: string;
|
bearerToken: string;
|
||||||
setBearerToken: (token: string) => void;
|
setBearerToken: (token: string) => void;
|
||||||
|
headerName?: string;
|
||||||
|
setHeaderName?: (name: string) => void;
|
||||||
|
customHeaders: [string, string][];
|
||||||
|
setCustomHeaders: (headers: [string, string][]) => void;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
onDisconnect: () => void;
|
onDisconnect: () => void;
|
||||||
stdErrNotifications: StdErrNotification[];
|
stdErrNotifications: StdErrNotification[];
|
||||||
|
clearStdErrNotifications: () => void;
|
||||||
logLevel: LoggingLevel;
|
logLevel: LoggingLevel;
|
||||||
sendLogLevelRequest: (level: LoggingLevel) => void;
|
sendLogLevelRequest: (level: LoggingLevel) => void;
|
||||||
loggingSupported: boolean;
|
loggingSupported: boolean;
|
||||||
@@ -75,9 +84,14 @@ const Sidebar = ({
|
|||||||
setEnv,
|
setEnv,
|
||||||
bearerToken,
|
bearerToken,
|
||||||
setBearerToken,
|
setBearerToken,
|
||||||
|
headerName,
|
||||||
|
setHeaderName,
|
||||||
|
customHeaders,
|
||||||
|
setCustomHeaders,
|
||||||
onConnect,
|
onConnect,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
stdErrNotifications,
|
stdErrNotifications,
|
||||||
|
clearStdErrNotifications,
|
||||||
logLevel,
|
logLevel,
|
||||||
sendLogLevelRequest,
|
sendLogLevelRequest,
|
||||||
loggingSupported,
|
loggingSupported,
|
||||||
@@ -89,10 +103,141 @@ const Sidebar = ({
|
|||||||
const [showBearerToken, setShowBearerToken] = useState(false);
|
const [showBearerToken, setShowBearerToken] = useState(false);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
|
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
|
||||||
|
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
|
||||||
|
const [copiedServerFile, setCopiedServerFile] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [showCustomHeaders, setShowCustomHeaders] = useState(false);
|
||||||
|
|
||||||
|
// Reusable error reporter for copy actions
|
||||||
|
const reportError = useCallback(
|
||||||
|
(error: unknown) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: `Failed to copy config: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[toast],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shared utility function to generate server config
|
||||||
|
const generateServerConfig = useCallback(() => {
|
||||||
|
if (transportType === "stdio") {
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
args: args.trim() ? args.split(/\s+/) : [],
|
||||||
|
env: { ...env },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (transportType === "sse") {
|
||||||
|
return {
|
||||||
|
type: "sse",
|
||||||
|
url: sseUrl,
|
||||||
|
note: "For SSE connections, add this URL directly in Client",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (transportType === "streamable-http") {
|
||||||
|
return {
|
||||||
|
type: "streamable-http",
|
||||||
|
url: sseUrl,
|
||||||
|
note: "For Streamable HTTP connections, add this URL directly in Client",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}, [transportType, command, args, env, sseUrl]);
|
||||||
|
|
||||||
|
// Memoized config entry generator
|
||||||
|
const generateMCPServerEntry = useCallback(() => {
|
||||||
|
return JSON.stringify(generateServerConfig(), null, 4);
|
||||||
|
}, [generateServerConfig]);
|
||||||
|
|
||||||
|
// Memoized config file generator
|
||||||
|
const generateMCPServerFile = useCallback(() => {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
"default-server": generateServerConfig(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
}, [generateServerConfig]);
|
||||||
|
|
||||||
|
// Memoized copy handlers
|
||||||
|
const handleCopyServerEntry = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const configJson = generateMCPServerEntry();
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(configJson)
|
||||||
|
.then(() => {
|
||||||
|
setCopiedServerEntry(true);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Config entry copied",
|
||||||
|
description:
|
||||||
|
transportType === "stdio"
|
||||||
|
? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name."
|
||||||
|
: "SSE URL has been copied. Use this URL in Cursor directly.",
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopiedServerEntry(false);
|
||||||
|
}, 2000);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reportError(error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reportError(error);
|
||||||
|
}
|
||||||
|
}, [generateMCPServerEntry, transportType, toast, reportError]);
|
||||||
|
|
||||||
|
const handleCopyServerFile = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const configJson = generateMCPServerFile();
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(configJson)
|
||||||
|
.then(() => {
|
||||||
|
setCopiedServerFile(true);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Servers file copied",
|
||||||
|
description:
|
||||||
|
"Servers configuration has been copied to clipboard. Add this to your mcp.json file. Current testing server will be added as 'default-server'",
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopiedServerFile(false);
|
||||||
|
}, 2000);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reportError(error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reportError(error);
|
||||||
|
}
|
||||||
|
}, [generateMCPServerFile, toast, reportError]);
|
||||||
|
|
||||||
|
const removeCustomHeader = (index: number) => {
|
||||||
|
const newHeaders = [...customHeaders];
|
||||||
|
newHeaders.splice(index, 1);
|
||||||
|
setCustomHeaders(newHeaders);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCustomHeader = (index: number, field: 'key' | 'value', value: string) => {
|
||||||
|
const newArr = [...customHeaders];
|
||||||
|
const [oldKey, oldValue] = newArr[index];
|
||||||
|
const newTuple: [string, string] = field === 'key'
|
||||||
|
? [value, oldValue]
|
||||||
|
: [oldKey, value];
|
||||||
|
newArr[index] = newTuple;
|
||||||
|
setCustomHeaders(newArr);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
|
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<h1 className="ml-2 text-lg font-semibold">
|
<h1 className="ml-2 text-lg font-semibold">
|
||||||
MCP Inspector v{version}
|
MCP Inspector v{version}
|
||||||
@@ -103,19 +248,25 @@ const Sidebar = ({
|
|||||||
<div className="p-4 flex-1 overflow-auto">
|
<div className="p-4 flex-1 overflow-auto">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Transport Type</label>
|
<label
|
||||||
|
className="text-sm font-medium"
|
||||||
|
htmlFor="transport-type-select"
|
||||||
|
>
|
||||||
|
Transport Type
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={transportType}
|
value={transportType}
|
||||||
onValueChange={(value: "stdio" | "sse") =>
|
onValueChange={(value: "stdio" | "sse" | "streamable-http") =>
|
||||||
setTransportType(value)
|
setTransportType(value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="transport-type-select">
|
||||||
<SelectValue placeholder="Select transport type" />
|
<SelectValue placeholder="Select transport type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="stdio">STDIO</SelectItem>
|
<SelectItem value="stdio">STDIO</SelectItem>
|
||||||
<SelectItem value="sse">SSE</SelectItem>
|
<SelectItem value="sse">SSE</SelectItem>
|
||||||
|
<SelectItem value="streamable-http">Streamable HTTP</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,8 +274,11 @@ const Sidebar = ({
|
|||||||
{transportType === "stdio" ? (
|
{transportType === "stdio" ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Command</label>
|
<label className="text-sm font-medium" htmlFor="command-input">
|
||||||
|
Command
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="command-input"
|
||||||
placeholder="Command"
|
placeholder="Command"
|
||||||
value={command}
|
value={command}
|
||||||
onChange={(e) => setCommand(e.target.value)}
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
@@ -132,8 +286,14 @@ const Sidebar = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Arguments</label>
|
<label
|
||||||
|
className="text-sm font-medium"
|
||||||
|
htmlFor="arguments-input"
|
||||||
|
>
|
||||||
|
Arguments
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="arguments-input"
|
||||||
placeholder="Arguments (space-separated)"
|
placeholder="Arguments (space-separated)"
|
||||||
value={args}
|
value={args}
|
||||||
onChange={(e) => setArgs(e.target.value)}
|
onChange={(e) => setArgs(e.target.value)}
|
||||||
@@ -144,8 +304,11 @@ const Sidebar = ({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">URL</label>
|
<label className="text-sm font-medium" htmlFor="sse-url-input">
|
||||||
|
URL
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="sse-url-input"
|
||||||
placeholder="URL"
|
placeholder="URL"
|
||||||
value={sseUrl}
|
value={sseUrl}
|
||||||
onChange={(e) => setSseUrl(e.target.value)}
|
onChange={(e) => setSseUrl(e.target.value)}
|
||||||
@@ -157,6 +320,8 @@ const Sidebar = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowBearerToken(!showBearerToken)}
|
onClick={() => setShowBearerToken(!showBearerToken)}
|
||||||
className="flex items-center w-full"
|
className="flex items-center w-full"
|
||||||
|
data-testid="auth-button"
|
||||||
|
aria-expanded={showBearerToken}
|
||||||
>
|
>
|
||||||
{showBearerToken ? (
|
{showBearerToken ? (
|
||||||
<ChevronDown className="w-4 h-4 mr-2" />
|
<ChevronDown className="w-4 h-4 mr-2" />
|
||||||
@@ -167,11 +332,28 @@ const Sidebar = ({
|
|||||||
</Button>
|
</Button>
|
||||||
{showBearerToken && (
|
{showBearerToken && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Bearer Token</label>
|
<label className="text-sm font-medium">Header Name</label>
|
||||||
<Input
|
<Input
|
||||||
|
placeholder="Authorization"
|
||||||
|
onChange={(e) =>
|
||||||
|
setHeaderName && setHeaderName(e.target.value)
|
||||||
|
}
|
||||||
|
data-testid="header-input"
|
||||||
|
className="font-mono"
|
||||||
|
value={headerName}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="text-sm font-medium"
|
||||||
|
htmlFor="bearer-token-input"
|
||||||
|
>
|
||||||
|
Bearer Token
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="bearer-token-input"
|
||||||
placeholder="Bearer Token"
|
placeholder="Bearer Token"
|
||||||
value={bearerToken}
|
value={bearerToken}
|
||||||
onChange={(e) => setBearerToken(e.target.value)}
|
onChange={(e) => setBearerToken(e.target.value)}
|
||||||
|
data-testid="bearer-token-input"
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
type="password"
|
type="password"
|
||||||
/>
|
/>
|
||||||
@@ -180,6 +362,64 @@ const Sidebar = ({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{transportType === "sse" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowCustomHeaders(!showCustomHeaders)}
|
||||||
|
className="flex items-center w-full"
|
||||||
|
data-testid="custom-headers-button"
|
||||||
|
aria-expanded={showCustomHeaders}
|
||||||
|
>
|
||||||
|
{showCustomHeaders ? (
|
||||||
|
<ChevronDown className="w-4 h-4 mr-2" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Custom Headers
|
||||||
|
</Button>
|
||||||
|
{showCustomHeaders && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{customHeaders.map((header, index) => (
|
||||||
|
<div key={index} className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Header Name</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Header Name"
|
||||||
|
value={header[0]}
|
||||||
|
onChange={(e) => updateCustomHeader(index, 'key', e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<label className="text-sm font-medium">Header Value</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Header Value"
|
||||||
|
value={header[1]}
|
||||||
|
onChange={(e) => updateCustomHeader(index, 'value', e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeCustomHeader(index)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Remove Header
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full mt-2"
|
||||||
|
onClick={() => {
|
||||||
|
setCustomHeaders([ ...customHeaders, ["", ""]]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Custom Header
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
{transportType === "stdio" && (
|
{transportType === "stdio" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -187,6 +427,7 @@ const Sidebar = ({
|
|||||||
onClick={() => setShowEnvVars(!showEnvVars)}
|
onClick={() => setShowEnvVars(!showEnvVars)}
|
||||||
className="flex items-center w-full"
|
className="flex items-center w-full"
|
||||||
data-testid="env-vars-button"
|
data-testid="env-vars-button"
|
||||||
|
aria-expanded={showEnvVars}
|
||||||
>
|
>
|
||||||
{showEnvVars ? (
|
{showEnvVars ? (
|
||||||
<ChevronDown className="w-4 h-4 mr-2" />
|
<ChevronDown className="w-4 h-4 mr-2" />
|
||||||
@@ -201,6 +442,7 @@ const Sidebar = ({
|
|||||||
<div key={idx} className="space-y-2 pb-4">
|
<div key={idx} className="space-y-2 pb-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
aria-label={`Environment variable key ${idx + 1}`}
|
||||||
placeholder="Key"
|
placeholder="Key"
|
||||||
value={key}
|
value={key}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -243,6 +485,7 @@ const Sidebar = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
aria-label={`Environment variable value ${idx + 1}`}
|
||||||
type={shownEnvVars.has(key) ? "text" : "password"}
|
type={shownEnvVars.has(key) ? "text" : "password"}
|
||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
value={value}
|
value={value}
|
||||||
@@ -302,6 +545,46 @@ const Sidebar = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Always show both copy buttons for all transport types */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopyServerEntry}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{copiedServerEntry ? (
|
||||||
|
<CheckCheck className="h-4 w-4 mr-2" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Server Entry
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Copy Server Entry</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopyServerFile}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{copiedServerFile ? (
|
||||||
|
<CheckCheck className="h-4 w-4 mr-2" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Servers File
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Copy Servers File</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Configuration */}
|
{/* Configuration */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -309,6 +592,7 @@ const Sidebar = ({
|
|||||||
onClick={() => setShowConfig(!showConfig)}
|
onClick={() => setShowConfig(!showConfig)}
|
||||||
className="flex items-center w-full"
|
className="flex items-center w-full"
|
||||||
data-testid="config-button"
|
data-testid="config-button"
|
||||||
|
aria-expanded={showConfig}
|
||||||
>
|
>
|
||||||
{showConfig ? (
|
{showConfig ? (
|
||||||
<ChevronDown className="w-4 h-4 mr-2" />
|
<ChevronDown className="w-4 h-4 mr-2" />
|
||||||
@@ -325,8 +609,11 @@ const Sidebar = ({
|
|||||||
return (
|
return (
|
||||||
<div key={key} className="space-y-2">
|
<div key={key} className="space-y-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<label className="text-sm font-medium text-green-600">
|
<label
|
||||||
{configKey}
|
className="text-sm font-medium text-green-600 break-all"
|
||||||
|
htmlFor={`${configKey}-input`}
|
||||||
|
>
|
||||||
|
{configItem.label}
|
||||||
</label>
|
</label>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -339,6 +626,7 @@ const Sidebar = ({
|
|||||||
</div>
|
</div>
|
||||||
{typeof configItem.value === "number" ? (
|
{typeof configItem.value === "number" ? (
|
||||||
<Input
|
<Input
|
||||||
|
id={`${configKey}-input`}
|
||||||
type="number"
|
type="number"
|
||||||
data-testid={`${configKey}-input`}
|
data-testid={`${configKey}-input`}
|
||||||
value={configItem.value}
|
value={configItem.value}
|
||||||
@@ -365,7 +653,7 @@ const Sidebar = ({
|
|||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id={`${configKey}-input`}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -375,6 +663,7 @@ const Sidebar = ({
|
|||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
|
id={`${configKey}-input`}
|
||||||
data-testid={`${configKey}-input`}
|
data-testid={`${configKey}-input`}
|
||||||
value={configItem.value}
|
value={configItem.value}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -398,7 +687,13 @@ const Sidebar = ({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{connectionStatus === "connected" && (
|
{connectionStatus === "connected" && (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Button data-testid="connect-button" onClick={onConnect}>
|
<Button
|
||||||
|
data-testid="connect-button"
|
||||||
|
onClick={() => {
|
||||||
|
onDisconnect();
|
||||||
|
onConnect();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<RotateCcw className="w-4 h-4 mr-2" />
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
{transportType === "stdio" ? "Restart" : "Reconnect"}
|
{transportType === "stdio" ? "Restart" : "Reconnect"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -448,19 +743,26 @@ const Sidebar = ({
|
|||||||
|
|
||||||
{loggingSupported && connectionStatus === "connected" && (
|
{loggingSupported && connectionStatus === "connected" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Logging Level</label>
|
<label
|
||||||
|
className="text-sm font-medium"
|
||||||
|
htmlFor="logging-level-select"
|
||||||
|
>
|
||||||
|
Logging Level
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={logLevel}
|
value={logLevel}
|
||||||
onValueChange={(value: LoggingLevel) =>
|
onValueChange={(value: LoggingLevel) =>
|
||||||
sendLogLevelRequest(value)
|
sendLogLevelRequest(value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="logging-level-select">
|
||||||
<SelectValue placeholder="Select logging level" />
|
<SelectValue placeholder="Select logging level" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Object.values(LoggingLevelSchema.enum).map((level) => (
|
{Object.values(LoggingLevelSchema.enum).map((level) => (
|
||||||
<SelectItem value={level}>{level}</SelectItem>
|
<SelectItem key={level} value={level}>
|
||||||
|
{level}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -470,9 +772,19 @@ const Sidebar = ({
|
|||||||
{stdErrNotifications.length > 0 && (
|
{stdErrNotifications.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-4 border-t border-gray-200 pt-4">
|
<div className="mt-4 border-t border-gray-200 pt-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
<h3 className="text-sm font-medium">
|
<h3 className="text-sm font-medium">
|
||||||
Error output from MCP server
|
Error output from MCP server
|
||||||
</h3>
|
</h3>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearStdErrNotifications}
|
||||||
|
className="h-8 px-2"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="mt-2 max-h-80 overflow-y-auto">
|
<div className="mt-2 max-h-80 overflow-y-auto">
|
||||||
{stdErrNotifications.map((notification, index) => (
|
{stdErrNotifications.map((notification, index) => (
|
||||||
<div
|
<div
|
||||||
@@ -487,6 +799,8 @@ const Sidebar = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 border-t">
|
<div className="p-4 border-t">
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { TabsContent } from "@/components/ui/tabs";
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm";
|
import DynamicJsonForm from "./DynamicJsonForm";
|
||||||
|
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||||
import {
|
import {
|
||||||
CallToolResultSchema,
|
CallToolResultSchema,
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
ListToolsResult,
|
ListToolsResult,
|
||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { Send } from "lucide-react";
|
import { Loader2, Send } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ListPane from "./ListPane";
|
import ListPane from "./ListPane";
|
||||||
import JsonView from "./JsonView";
|
import JsonView from "./JsonView";
|
||||||
@@ -31,7 +32,7 @@ const ToolsTab = ({
|
|||||||
tools: Tool[];
|
tools: Tool[];
|
||||||
listTools: () => void;
|
listTools: () => void;
|
||||||
clearTools: () => void;
|
clearTools: () => void;
|
||||||
callTool: (name: string, params: Record<string, unknown>) => void;
|
callTool: (name: string, params: Record<string, unknown>) => Promise<void>;
|
||||||
selectedTool: Tool | null;
|
selectedTool: Tool | null;
|
||||||
setSelectedTool: (tool: Tool | null) => void;
|
setSelectedTool: (tool: Tool | null) => void;
|
||||||
toolResult: CompatibilityCallToolResult | null;
|
toolResult: CompatibilityCallToolResult | null;
|
||||||
@@ -39,10 +40,25 @@ const ToolsTab = ({
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||||
|
const [isToolRunning, setIsToolRunning] = useState(false);
|
||||||
|
const [filterText, setFilterText] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setParams({});
|
const params = Object.entries(
|
||||||
|
selectedTool?.inputSchema.properties ?? [],
|
||||||
|
).map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
generateDefaultValue(value as JsonSchemaType),
|
||||||
|
]);
|
||||||
|
setParams(Object.fromEntries(params));
|
||||||
}, [selectedTool]);
|
}, [selectedTool]);
|
||||||
|
|
||||||
|
const filteredTools = filterText
|
||||||
|
? tools.filter((tool) =>
|
||||||
|
tool.name.toLowerCase().includes(filterText.toLowerCase())
|
||||||
|
)
|
||||||
|
: tools;
|
||||||
|
|
||||||
const renderToolResult = () => {
|
const renderToolResult = () => {
|
||||||
if (!toolResult) return null;
|
if (!toolResult) return null;
|
||||||
|
|
||||||
@@ -66,11 +82,18 @@ const ToolsTab = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4 className="font-semibold mb-2">
|
<h4 className="font-semibold mb-2">
|
||||||
Tool Result: {isError ? "Error" : "Success"}
|
Tool Result:{" "}
|
||||||
|
{isError ? (
|
||||||
|
<span className="text-red-600 font-semibold">Error</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-600 font-semibold">Success</span>
|
||||||
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
{structuredResult.content.map((item, index) => (
|
{structuredResult.content.map((item, index) => (
|
||||||
<div key={index} className="mb-2">
|
<div key={index} className="mb-2">
|
||||||
{item.type === "text" && <JsonView data={item.text} />}
|
{item.type === "text" && (
|
||||||
|
<JsonView data={item.text} isError={isError} />
|
||||||
|
)}
|
||||||
{item.type === "image" && (
|
{item.type === "image" && (
|
||||||
<img
|
<img
|
||||||
src={`data:${item.mimeType};base64,${item.data}`}
|
src={`data:${item.mimeType};base64,${item.data}`}
|
||||||
@@ -106,9 +129,17 @@ const ToolsTab = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent value="tools" className="grid grid-cols-2 gap-4">
|
<TabsContent value="tools">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter tools by name..."
|
||||||
|
value={filterText}
|
||||||
|
onChange={(e) => setFilterText(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
<ListPane
|
<ListPane
|
||||||
items={tools}
|
items={filteredTools}
|
||||||
listItems={listTools}
|
listItems={listTools}
|
||||||
clearItems={() => {
|
clearItems={() => {
|
||||||
clearTools();
|
clearTools();
|
||||||
@@ -116,20 +147,20 @@ const ToolsTab = ({
|
|||||||
}}
|
}}
|
||||||
setSelectedItem={setSelectedTool}
|
setSelectedItem={setSelectedTool}
|
||||||
renderItem={(tool) => (
|
renderItem={(tool) => (
|
||||||
<>
|
<div className="flex flex-col items-start">
|
||||||
<span className="flex-1">{tool.name}</span>
|
<span className="flex-1">{tool.name}</span>
|
||||||
<span className="text-sm text-gray-500 text-right">
|
<span className="text-sm text-gray-500 text-left">
|
||||||
{tool.description}
|
{tool.description}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
title="Tools"
|
title="Tools"
|
||||||
buttonText={nextCursor ? "List More Tools" : "List Tools"}
|
buttonText={nextCursor ? "List More Tools" : "List Tools"}
|
||||||
isButtonDisabled={!nextCursor && tools.length > 0}
|
isButtonDisabled={!nextCursor && tools.length > 0}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="bg-card rounded-lg shadow">
|
<div className="bg-card rounded-lg shadow">
|
||||||
<div className="p-4 border-b border-gray-200">
|
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||||
<h3 className="font-semibold">
|
<h3 className="font-semibold">
|
||||||
{selectedTool ? selectedTool.name : "Select a tool"}
|
{selectedTool ? selectedTool.name : "Select a tool"}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -206,13 +237,10 @@ const ToolsTab = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : prop.type === "number" ||
|
||||||
|
prop.type === "integer" ? (
|
||||||
<Input
|
<Input
|
||||||
type={
|
type="number"
|
||||||
prop.type === "number" || prop.type === "integer"
|
|
||||||
? "number"
|
|
||||||
: "text"
|
|
||||||
}
|
|
||||||
id={key}
|
id={key}
|
||||||
name={key}
|
name={key}
|
||||||
placeholder={prop.description}
|
placeholder={prop.description}
|
||||||
@@ -220,23 +248,56 @@ const ToolsTab = ({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setParams({
|
setParams({
|
||||||
...params,
|
...params,
|
||||||
[key]:
|
[key]: Number(e.target.value),
|
||||||
prop.type === "number" ||
|
|
||||||
prop.type === "integer"
|
|
||||||
? Number(e.target.value)
|
|
||||||
: e.target.value,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1">
|
||||||
|
<DynamicJsonForm
|
||||||
|
schema={{
|
||||||
|
type: prop.type,
|
||||||
|
properties: prop.properties,
|
||||||
|
description: prop.description,
|
||||||
|
items: prop.items,
|
||||||
|
}}
|
||||||
|
value={params[key] as JsonValue}
|
||||||
|
onChange={(newValue: JsonValue) => {
|
||||||
|
setParams({
|
||||||
|
...params,
|
||||||
|
[key]: newValue,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => callTool(selectedTool.name, params)}>
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
setIsToolRunning(true);
|
||||||
|
await callTool(selectedTool.name, params);
|
||||||
|
} finally {
|
||||||
|
setIsToolRunning(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isToolRunning}
|
||||||
|
>
|
||||||
|
{isToolRunning ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Running...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Send className="w-4 h-4 mr-2" />
|
<Send className="w-4 h-4 mr-2" />
|
||||||
Run Tool
|
Run Tool
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{toolResult && renderToolResult()}
|
{toolResult && renderToolResult()}
|
||||||
</div>
|
</div>
|
||||||
@@ -249,6 +310,7 @@ const ToolsTab = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
382
client/src/components/__tests__/AuthDebugger.test.tsx
Normal file
382
client/src/components/__tests__/AuthDebugger.test.tsx
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
act,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { describe, it, beforeEach, jest } from "@jest/globals";
|
||||||
|
import AuthDebugger, { AuthDebuggerProps } from "../AuthDebugger";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { SESSION_KEYS } from "@/lib/constants";
|
||||||
|
|
||||||
|
const mockOAuthTokens = {
|
||||||
|
access_token: "test_access_token",
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: 3600,
|
||||||
|
refresh_token: "test_refresh_token",
|
||||||
|
scope: "test_scope",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockOAuthMetadata = {
|
||||||
|
issuer: "https://oauth.example.com",
|
||||||
|
authorization_endpoint: "https://oauth.example.com/authorize",
|
||||||
|
token_endpoint: "https://oauth.example.com/token",
|
||||||
|
response_types_supported: ["code"],
|
||||||
|
grant_types_supported: ["authorization_code"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockOAuthClientInfo = {
|
||||||
|
client_id: "test_client_id",
|
||||||
|
client_secret: "test_client_secret",
|
||||||
|
redirect_uris: ["http://localhost:3000/oauth/callback/debug"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock MCP SDK functions - must be before imports
|
||||||
|
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||||
|
auth: jest.fn(),
|
||||||
|
discoverOAuthMetadata: jest.fn(),
|
||||||
|
registerClient: jest.fn(),
|
||||||
|
startAuthorization: jest.fn(),
|
||||||
|
exchangeAuthorization: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import the functions to get their types
|
||||||
|
import {
|
||||||
|
discoverOAuthMetadata,
|
||||||
|
registerClient,
|
||||||
|
startAuthorization,
|
||||||
|
exchangeAuthorization,
|
||||||
|
} from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
|
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
|
||||||
|
// Type the mocked functions properly
|
||||||
|
const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction<
|
||||||
|
typeof discoverOAuthMetadata
|
||||||
|
>;
|
||||||
|
const mockRegisterClient = registerClient as jest.MockedFunction<
|
||||||
|
typeof registerClient
|
||||||
|
>;
|
||||||
|
const mockStartAuthorization = startAuthorization as jest.MockedFunction<
|
||||||
|
typeof startAuthorization
|
||||||
|
>;
|
||||||
|
const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction<
|
||||||
|
typeof exchangeAuthorization
|
||||||
|
>;
|
||||||
|
|
||||||
|
const sessionStorageMock = {
|
||||||
|
getItem: jest.fn(),
|
||||||
|
setItem: jest.fn(),
|
||||||
|
removeItem: jest.fn(),
|
||||||
|
clear: jest.fn(),
|
||||||
|
};
|
||||||
|
Object.defineProperty(window, "sessionStorage", {
|
||||||
|
value: sessionStorageMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
value: {
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AuthDebugger", () => {
|
||||||
|
const defaultAuthState = {
|
||||||
|
isInitiatingAuth: false,
|
||||||
|
oauthTokens: null,
|
||||||
|
loading: false,
|
||||||
|
oauthStep: "metadata_discovery" as const,
|
||||||
|
oauthMetadata: null,
|
||||||
|
oauthClientInfo: null,
|
||||||
|
authorizationUrl: null,
|
||||||
|
authorizationCode: "",
|
||||||
|
latestError: null,
|
||||||
|
statusMessage: null,
|
||||||
|
validationError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
serverUrl: "https://example.com",
|
||||||
|
onBack: jest.fn(),
|
||||||
|
authState: defaultAuthState,
|
||||||
|
updateAuthState: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
sessionStorageMock.getItem.mockReturnValue(null);
|
||||||
|
|
||||||
|
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
|
||||||
|
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
|
||||||
|
mockStartAuthorization.mockImplementation(async (_sseUrl, options) => {
|
||||||
|
const authUrl = new URL("https://oauth.example.com/authorize");
|
||||||
|
|
||||||
|
if (options.scope) {
|
||||||
|
authUrl.searchParams.set("scope", options.scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authorizationUrl: authUrl,
|
||||||
|
codeVerifier: "test_verifier",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderAuthDebugger = (props: Partial<AuthDebuggerProps> = {}) => {
|
||||||
|
const mergedProps = {
|
||||||
|
...defaultProps,
|
||||||
|
...props,
|
||||||
|
authState: { ...defaultAuthState, ...(props.authState || {}) },
|
||||||
|
};
|
||||||
|
return render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<AuthDebugger {...mergedProps} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Initial Rendering", () => {
|
||||||
|
it("should render the component with correct title", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
renderAuthDebugger();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Authentication Settings")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onBack when Back button is clicked", async () => {
|
||||||
|
const onBack = jest.fn();
|
||||||
|
await act(async () => {
|
||||||
|
renderAuthDebugger({ onBack });
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText("Back to Connect"));
|
||||||
|
expect(onBack).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OAuth Flow", () => {
|
||||||
|
it("should start OAuth flow when 'Guided OAuth Flow' is clicked", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
renderAuthDebugger();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText("Guided OAuth Flow"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("OAuth Flow Progress")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show error when OAuth flow is started without sseUrl", async () => {
|
||||||
|
const updateAuthState = jest.fn();
|
||||||
|
await act(async () => {
|
||||||
|
renderAuthDebugger({ serverUrl: "", updateAuthState });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText("Guided OAuth Flow"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateAuthState).toHaveBeenCalledWith({
|
||||||
|
statusMessage: {
|
||||||
|
type: "error",
|
||||||
|
message:
|
||||||
|
"Please enter a server URL in the sidebar before authenticating",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Session Storage Integration", () => {
|
||||||
|
it("should load OAuth tokens from session storage", async () => {
|
||||||
|
// Mock the specific key for tokens with server URL
|
||||||
|
sessionStorageMock.getItem.mockImplementation((key) => {
|
||||||
|
if (key === "[https://example.com] mcp_tokens") {
|
||||||
|
return JSON.stringify(mockOAuthTokens);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
renderAuthDebugger({
|
||||||
|
authState: {
|
||||||
|
...defaultAuthState,
|
||||||
|
oauthTokens: mockOAuthTokens,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Access Token:/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle errors loading OAuth tokens from session storage", async () => {
|
||||||
|
// Mock console to avoid cluttering test output
|
||||||
|
const originalError = console.error;
|
||||||
|
console.error = jest.fn();
|
||||||
|
|
||||||
|
// Mock getItem to return invalid JSON for tokens
|
||||||
|
sessionStorageMock.getItem.mockImplementation((key) => {
|
||||||
|
if (key === "[https://example.com] mcp_tokens") {
|
||||||
|
return "invalid json";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
renderAuthDebugger();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component should still render despite the error
|
||||||
|
expect(screen.getByText("Authentication Settings")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Restore console.error
|
||||||
|
console.error = originalError;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OAuth State Management", () => {
|
||||||
|
it("should clear OAuth state when Clear button is clicked", async () => {
|
||||||
|
const updateAuthState = jest.fn();
|
||||||
|
// Mock the session storage to return tokens for the specific key
|
||||||
|
sessionStorageMock.getItem.mockImplementation((key) => {
|
||||||
|
if (key === "[https://example.com] mcp_tokens") {
|
||||||
|
return JSON.stringify(mockOAuthTokens);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
renderAuthDebugger({
|
||||||
|
authState: {
|
||||||
|
...defaultAuthState,
|
||||||
|
oauthTokens: mockOAuthTokens,
|
||||||
|
},
|
||||||
|
updateAuthState,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText("Clear OAuth State"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateAuthState).toHaveBeenCalledWith({
|
||||||
|
oauthTokens: null,
|
||||||
|
oauthStep: "metadata_discovery",
|
||||||
|
latestError: null,
|
||||||
|
oauthClientInfo: null,
|
||||||
|
oauthMetadata: null,
|
||||||
|
authorizationCode: "",
|
||||||
|
validationError: null,
|
||||||
|
statusMessage: {
|
||||||
|
type: "success",
|
||||||
|
message: "OAuth tokens cleared successfully",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify session storage was cleared
|
||||||
|
expect(sessionStorageMock.removeItem).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OAuth Flow Steps", () => {
|
||||||
|
it("should handle OAuth flow step progression", async () => {
|
||||||
|
const updateAuthState = jest.fn();
|
||||||
|
await act(async () => {
|
||||||
|
renderAuthDebugger({
|
||||||
|
updateAuthState,
|
||||||
|
authState: {
|
||||||
|
...defaultAuthState,
|
||||||
|
isInitiatingAuth: false, // Changed to false so button is enabled
|
||||||
|
oauthStep: "metadata_discovery",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify metadata discovery step
|
||||||
|
expect(screen.getByText("Metadata Discovery")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click Continue - this should trigger metadata discovery
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText("Continue"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
||||||
|
"https://example.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup helper for OAuth authorization tests
|
||||||
|
const setupAuthorizationUrlTest = async (metadata: OAuthMetadata) => {
|
||||||
|
const updateAuthState = jest.fn();
|
||||||
|
|
||||||
|
// Mock the session storage to return metadata
|
||||||
|
sessionStorageMock.getItem.mockImplementation((key) => {
|
||||||
|
if (key === `[https://example.com] ${SESSION_KEYS.SERVER_METADATA}`) {
|
||||||
|
return JSON.stringify(metadata);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
key === `[https://example.com] ${SESSION_KEYS.CLIENT_INFORMATION}`
|
||||||
|
) {
|
||||||
|
return JSON.stringify(mockOAuthClientInfo);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
renderAuthDebugger({
|
||||||
|
updateAuthState,
|
||||||
|
authState: {
|
||||||
|
...defaultAuthState,
|
||||||
|
isInitiatingAuth: false,
|
||||||
|
oauthStep: "authorization_redirect",
|
||||||
|
oauthMetadata: metadata,
|
||||||
|
oauthClientInfo: mockOAuthClientInfo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click Continue to trigger authorization
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText("Continue"));
|
||||||
|
});
|
||||||
|
|
||||||
|
return updateAuthState;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should include scope in authorization URL when scopes_supported is present", async () => {
|
||||||
|
const metadataWithScopes = {
|
||||||
|
...mockOAuthMetadata,
|
||||||
|
scopes_supported: ["read", "write", "admin"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAuthState =
|
||||||
|
await setupAuthorizationUrlTest(metadataWithScopes);
|
||||||
|
|
||||||
|
// Wait for the updateAuthState to be called
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(updateAuthState).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
authorizationUrl: expect.stringContaining("scope="),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not include scope in authorization URL when scopes_supported is not present", async () => {
|
||||||
|
const updateAuthState =
|
||||||
|
await setupAuthorizationUrlTest(mockOAuthMetadata);
|
||||||
|
|
||||||
|
// Wait for the updateAuthState to be called
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(updateAuthState).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
authorizationUrl: expect.not.stringContaining("scope="),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { describe, it, expect, jest } from "@jest/globals";
|
import { describe, it, expect, jest } from "@jest/globals";
|
||||||
import DynamicJsonForm from "../DynamicJsonForm";
|
import DynamicJsonForm from "../DynamicJsonForm";
|
||||||
import type { JsonSchemaType } from "../DynamicJsonForm";
|
import type { JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
|
|
||||||
describe("DynamicJsonForm String Fields", () => {
|
describe("DynamicJsonForm String Fields", () => {
|
||||||
const renderForm = (props = {}) => {
|
const renderForm = (props = {}) => {
|
||||||
@@ -93,3 +93,47 @@ describe("DynamicJsonForm Integer Fields", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("DynamicJsonForm Complex Fields", () => {
|
||||||
|
const renderForm = (props = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
// The simplified JsonSchemaType does not accept oneOf fields
|
||||||
|
// But they exist in the more-complete JsonSchema7Type
|
||||||
|
nested: { oneOf: [{ type: "string" }, { type: "integer" }] },
|
||||||
|
},
|
||||||
|
} as unknown as JsonSchemaType,
|
||||||
|
value: undefined,
|
||||||
|
onChange: jest.fn(),
|
||||||
|
};
|
||||||
|
return render(<DynamicJsonForm {...defaultProps} {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Basic Operations", () => {
|
||||||
|
it("should render textbox and autoformat button, but no switch-to-form button", () => {
|
||||||
|
renderForm();
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
expect(input).toHaveProperty("type", "textarea");
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
expect(buttons).toHaveLength(1);
|
||||||
|
expect(buttons[0]).toHaveProperty("textContent", "Format JSON");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass changed values to onChange", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
renderForm({ onChange });
|
||||||
|
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
fireEvent.change(input, {
|
||||||
|
target: { value: `{ "nested": "i am string" }` },
|
||||||
|
});
|
||||||
|
|
||||||
|
// The onChange handler is debounced when using the JSON view, so we need to wait a little bit
|
||||||
|
waitFor(() => {
|
||||||
|
expect(onChange).toHaveBeenCalledWith(`{ "nested": "i am string" }`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
import { describe, it, beforeEach, jest } from "@jest/globals";
|
import { describe, it, beforeEach, jest } from "@jest/globals";
|
||||||
import Sidebar from "../Sidebar";
|
import Sidebar from "../Sidebar";
|
||||||
import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants";
|
import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants";
|
||||||
@@ -6,12 +7,30 @@ import { InspectorConfig } from "@/lib/configurationTypes";
|
|||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
// Mock theme hook
|
// Mock theme hook
|
||||||
jest.mock("../../lib/useTheme", () => ({
|
jest.mock("../../lib/hooks/useTheme", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: () => ["light", jest.fn()],
|
default: () => ["light", jest.fn()],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Sidebar Environment Variables", () => {
|
// Mock toast hook
|
||||||
|
const mockToast = jest.fn();
|
||||||
|
jest.mock("@/lib/hooks/useToast", () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
toast: mockToast,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock navigator clipboard
|
||||||
|
const mockClipboardWrite = jest.fn(() => Promise.resolve());
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
value: {
|
||||||
|
writeText: mockClipboardWrite,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup fake timers
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
connectionStatus: "disconnected" as const,
|
connectionStatus: "disconnected" as const,
|
||||||
transportType: "stdio" as const,
|
transportType: "stdio" as const,
|
||||||
@@ -26,16 +45,22 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
setEnv: jest.fn(),
|
setEnv: jest.fn(),
|
||||||
bearerToken: "",
|
bearerToken: "",
|
||||||
setBearerToken: jest.fn(),
|
setBearerToken: jest.fn(),
|
||||||
|
headerName: "",
|
||||||
|
setHeaderName: jest.fn(),
|
||||||
|
customHeaders: [],
|
||||||
|
setCustomHeaders: jest.fn(),
|
||||||
onConnect: jest.fn(),
|
onConnect: jest.fn(),
|
||||||
onDisconnect: jest.fn(),
|
onDisconnect: jest.fn(),
|
||||||
stdErrNotifications: [],
|
stdErrNotifications: [],
|
||||||
logLevel: "info" as const,
|
clearStdErrNotifications: jest.fn(),
|
||||||
|
logLevel: "debug" as const,
|
||||||
sendLogLevelRequest: jest.fn(),
|
sendLogLevelRequest: jest.fn(),
|
||||||
loggingSupported: true,
|
loggingSupported: false,
|
||||||
config: DEFAULT_INSPECTOR_CONFIG,
|
config: DEFAULT_INSPECTOR_CONFIG,
|
||||||
setConfig: jest.fn(),
|
setConfig: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
describe("Sidebar Environment Variables", () => {
|
||||||
const renderSidebar = (props = {}) => {
|
const renderSidebar = (props = {}) => {
|
||||||
return render(
|
return render(
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -51,6 +76,7 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
jest.clearAllTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Basic Operations", () => {
|
describe("Basic Operations", () => {
|
||||||
@@ -108,6 +134,157 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Authentication", () => {
|
||||||
|
const openAuthSection = () => {
|
||||||
|
const button = screen.getByTestId("auth-button");
|
||||||
|
fireEvent.click(button);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should update bearer token", () => {
|
||||||
|
const setBearerToken = jest.fn();
|
||||||
|
renderSidebar({
|
||||||
|
bearerToken: "",
|
||||||
|
setBearerToken,
|
||||||
|
transportType: "sse", // Set transport type to SSE
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||||
|
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||||
|
|
||||||
|
expect(setBearerToken).toHaveBeenCalledWith("new_token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update header name", () => {
|
||||||
|
const setHeaderName = jest.fn();
|
||||||
|
renderSidebar({
|
||||||
|
headerName: "Authorization",
|
||||||
|
setHeaderName,
|
||||||
|
transportType: "sse",
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
const headerInput = screen.getByTestId("header-input");
|
||||||
|
fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } });
|
||||||
|
|
||||||
|
expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear bearer token", () => {
|
||||||
|
const setBearerToken = jest.fn();
|
||||||
|
renderSidebar({
|
||||||
|
bearerToken: "existing_token",
|
||||||
|
setBearerToken,
|
||||||
|
transportType: "sse", // Set transport type to SSE
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||||
|
fireEvent.change(tokenInput, { target: { value: "" } });
|
||||||
|
|
||||||
|
expect(setBearerToken).toHaveBeenCalledWith("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should properly render bearer token input", () => {
|
||||||
|
const { rerender } = renderSidebar({
|
||||||
|
bearerToken: "existing_token",
|
||||||
|
transportType: "sse", // Set transport type to SSE
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
// Token input should be a password field
|
||||||
|
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||||
|
expect(tokenInput).toHaveProperty("type", "password");
|
||||||
|
|
||||||
|
// Update the token
|
||||||
|
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||||
|
|
||||||
|
// Rerender with updated token
|
||||||
|
rerender(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar
|
||||||
|
{...defaultProps}
|
||||||
|
bearerToken="new_token"
|
||||||
|
transportType="sse"
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Token input should still exist after update
|
||||||
|
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain token visibility state after update", () => {
|
||||||
|
const { rerender } = renderSidebar({
|
||||||
|
bearerToken: "existing_token",
|
||||||
|
transportType: "sse", // Set transport type to SSE
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
// Token input should be a password field
|
||||||
|
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||||
|
expect(tokenInput).toHaveProperty("type", "password");
|
||||||
|
|
||||||
|
// Update the token
|
||||||
|
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||||
|
|
||||||
|
// Rerender with updated token
|
||||||
|
rerender(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar
|
||||||
|
{...defaultProps}
|
||||||
|
bearerToken="new_token"
|
||||||
|
transportType="sse"
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Token input should still exist after update
|
||||||
|
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain header name when toggling auth section", () => {
|
||||||
|
renderSidebar({
|
||||||
|
headerName: "X-API-Key",
|
||||||
|
transportType: "sse",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open auth section
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
// Verify header name is displayed
|
||||||
|
const headerInput = screen.getByTestId("header-input");
|
||||||
|
expect(headerInput).toHaveValue("X-API-Key");
|
||||||
|
|
||||||
|
// Close auth section
|
||||||
|
const authButton = screen.getByTestId("auth-button");
|
||||||
|
fireEvent.click(authButton);
|
||||||
|
|
||||||
|
// Reopen auth section
|
||||||
|
fireEvent.click(authButton);
|
||||||
|
|
||||||
|
// Verify header name is still preserved
|
||||||
|
expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display default header name when not specified", () => {
|
||||||
|
renderSidebar({
|
||||||
|
headerName: undefined,
|
||||||
|
transportType: "sse",
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
const headerInput = screen.getByTestId("header-input");
|
||||||
|
expect(headerInput).toHaveAttribute("placeholder", "Authorization");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Key Editing", () => {
|
describe("Key Editing", () => {
|
||||||
it("should maintain order when editing first key", () => {
|
it("should maintain order when editing first key", () => {
|
||||||
const setEnv = jest.fn();
|
const setEnv = jest.fn();
|
||||||
@@ -343,6 +520,7 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
expect(setConfig).toHaveBeenCalledWith(
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
label: "Request Timeout",
|
||||||
description: "Timeout for requests to the MCP server (ms)",
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
value: 5000,
|
value: 5000,
|
||||||
},
|
},
|
||||||
@@ -350,6 +528,56 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should update MCP server proxy address", () => {
|
||||||
|
const setConfig = jest.fn();
|
||||||
|
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||||
|
|
||||||
|
openConfigSection();
|
||||||
|
|
||||||
|
const proxyAddressInput = screen.getByTestId(
|
||||||
|
"MCP_PROXY_FULL_ADDRESS-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(proxyAddressInput, {
|
||||||
|
target: { value: "http://localhost:8080" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MCP_PROXY_FULL_ADDRESS: {
|
||||||
|
label: "Inspector Proxy Address",
|
||||||
|
description:
|
||||||
|
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
|
||||||
|
value: "http://localhost:8080",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update max total timeout", () => {
|
||||||
|
const setConfig = jest.fn();
|
||||||
|
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||||
|
|
||||||
|
openConfigSection();
|
||||||
|
|
||||||
|
const maxTotalTimeoutInput = screen.getByTestId(
|
||||||
|
"MCP_REQUEST_MAX_TOTAL_TIMEOUT-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(maxTotalTimeoutInput, {
|
||||||
|
target: { value: "10000" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
|
||||||
|
label: "Maximum Total Timeout",
|
||||||
|
description:
|
||||||
|
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
|
||||||
|
value: 10000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("should handle invalid timeout values entered by user", () => {
|
it("should handle invalid timeout values entered by user", () => {
|
||||||
const setConfig = jest.fn();
|
const setConfig = jest.fn();
|
||||||
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||||
@@ -364,6 +592,7 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
expect(setConfig).toHaveBeenCalledWith(
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
label: "Request Timeout",
|
||||||
description: "Timeout for requests to the MCP server (ms)",
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
value: 0,
|
value: 0,
|
||||||
},
|
},
|
||||||
@@ -409,6 +638,7 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
expect(setConfig).toHaveBeenLastCalledWith(
|
expect(setConfig).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
label: "Request Timeout",
|
||||||
description: "Timeout for requests to the MCP server (ms)",
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
value: 3000,
|
value: 3000,
|
||||||
},
|
},
|
||||||
@@ -416,4 +646,307 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Copy Configuration Features", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.clearAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const getCopyButtons = () => {
|
||||||
|
return {
|
||||||
|
serverEntry: screen.getByRole("button", { name: /server entry/i }),
|
||||||
|
serversFile: screen.getByRole("button", { name: /servers file/i }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should render both copy buttons for all transport types", () => {
|
||||||
|
["stdio", "sse", "streamable-http"].forEach((transportType) => {
|
||||||
|
renderSidebar({ transportType });
|
||||||
|
// There should be exactly one Server Entry and one Servers File button per render
|
||||||
|
const serverEntryButtons = screen.getAllByRole("button", {
|
||||||
|
name: /server entry/i,
|
||||||
|
});
|
||||||
|
const serversFileButtons = screen.getAllByRole("button", {
|
||||||
|
name: /servers file/i,
|
||||||
|
});
|
||||||
|
expect(serverEntryButtons).toHaveLength(1);
|
||||||
|
expect(serversFileButtons).toHaveLength(1);
|
||||||
|
// Clean up DOM for next iteration
|
||||||
|
// (Testing Library's render does not auto-unmount in a loop)
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should copy server entry configuration to clipboard for STDIO transport", async () => {
|
||||||
|
const command = "node";
|
||||||
|
const args = "--inspect server.js";
|
||||||
|
const env = { API_KEY: "test-key", DEBUG: "true" };
|
||||||
|
|
||||||
|
renderSidebar({
|
||||||
|
transportType: "stdio",
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const { serverEntry } = getCopyButtons();
|
||||||
|
fireEvent.click(serverEntry);
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
|
||||||
|
const expectedConfig = JSON.stringify(
|
||||||
|
{
|
||||||
|
command,
|
||||||
|
args: ["--inspect", "server.js"],
|
||||||
|
env,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should copy servers file configuration to clipboard for STDIO transport", async () => {
|
||||||
|
const command = "node";
|
||||||
|
const args = "--inspect server.js";
|
||||||
|
const env = { API_KEY: "test-key", DEBUG: "true" };
|
||||||
|
|
||||||
|
renderSidebar({
|
||||||
|
transportType: "stdio",
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const { serversFile } = getCopyButtons();
|
||||||
|
fireEvent.click(serversFile);
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
|
||||||
|
const expectedConfig = JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
"default-server": {
|
||||||
|
command,
|
||||||
|
args: ["--inspect", "server.js"],
|
||||||
|
env,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should copy server entry configuration to clipboard for SSE transport", async () => {
|
||||||
|
const sseUrl = "http://localhost:3000/events";
|
||||||
|
renderSidebar({ transportType: "sse", sseUrl });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const { serverEntry } = getCopyButtons();
|
||||||
|
fireEvent.click(serverEntry);
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
|
||||||
|
const expectedConfig = JSON.stringify(
|
||||||
|
{
|
||||||
|
type: "sse",
|
||||||
|
url: sseUrl,
|
||||||
|
note: "For SSE connections, add this URL directly in Client",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should copy servers file configuration to clipboard for SSE transport", async () => {
|
||||||
|
const sseUrl = "http://localhost:3000/events";
|
||||||
|
renderSidebar({ transportType: "sse", sseUrl });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const { serversFile } = getCopyButtons();
|
||||||
|
fireEvent.click(serversFile);
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
|
||||||
|
const expectedConfig = JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
"default-server": {
|
||||||
|
type: "sse",
|
||||||
|
url: sseUrl,
|
||||||
|
note: "For SSE connections, add this URL directly in Client",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should copy server entry configuration to clipboard for streamable-http transport", async () => {
|
||||||
|
const sseUrl = "http://localhost:3001/sse";
|
||||||
|
renderSidebar({ transportType: "streamable-http", sseUrl });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const { serverEntry } = getCopyButtons();
|
||||||
|
fireEvent.click(serverEntry);
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
|
||||||
|
const expectedConfig = JSON.stringify(
|
||||||
|
{
|
||||||
|
type: "streamable-http",
|
||||||
|
url: sseUrl,
|
||||||
|
note: "For Streamable HTTP connections, add this URL directly in Client",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should copy servers file configuration to clipboard for streamable-http transport", async () => {
|
||||||
|
const sseUrl = "http://localhost:3001/sse";
|
||||||
|
renderSidebar({ transportType: "streamable-http", sseUrl });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const { serversFile } = getCopyButtons();
|
||||||
|
fireEvent.click(serversFile);
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
|
||||||
|
const expectedConfig = JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
"default-server": {
|
||||||
|
type: "streamable-http",
|
||||||
|
url: sseUrl,
|
||||||
|
note: "For Streamable HTTP connections, add this URL directly in Client",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty args in STDIO transport", async () => {
|
||||||
|
const command = "python";
|
||||||
|
const args = "";
|
||||||
|
|
||||||
|
renderSidebar({
|
||||||
|
transportType: "stdio",
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const { serverEntry } = getCopyButtons();
|
||||||
|
fireEvent.click(serverEntry);
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
|
||||||
|
const expectedConfig = JSON.stringify(
|
||||||
|
{
|
||||||
|
command,
|
||||||
|
args: [],
|
||||||
|
env: {},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Sidebar", () => {
|
||||||
|
it("renders", () => {
|
||||||
|
render(<Sidebar {...defaultProps} />);
|
||||||
|
expect(screen.getByText("MCP Inspector")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows connect button when disconnected", () => {
|
||||||
|
render(<Sidebar {...defaultProps} />);
|
||||||
|
expect(screen.getByText("Connect")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows disconnect button when connected", () => {
|
||||||
|
render(
|
||||||
|
<Sidebar
|
||||||
|
{...defaultProps}
|
||||||
|
connectionStatus="connected"
|
||||||
|
customHeaders={[]}
|
||||||
|
setCustomHeaders={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Disconnect")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows reconnect button when connected", () => {
|
||||||
|
render(
|
||||||
|
<Sidebar
|
||||||
|
{...defaultProps}
|
||||||
|
connectionStatus="connected"
|
||||||
|
transportType="sse"
|
||||||
|
customHeaders={[]}
|
||||||
|
setCustomHeaders={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Reconnect")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows restart button when connected with stdio transport", () => {
|
||||||
|
render(
|
||||||
|
<Sidebar
|
||||||
|
{...defaultProps}
|
||||||
|
connectionStatus="connected"
|
||||||
|
transportType="stdio"
|
||||||
|
customHeaders={[]}
|
||||||
|
setCustomHeaders={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Restart")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows environment variables section when stdio transport is selected", () => {
|
||||||
|
render(
|
||||||
|
<Sidebar
|
||||||
|
{...defaultProps}
|
||||||
|
env={{ NEW_KEY: "new_value" }}
|
||||||
|
customHeaders={[]}
|
||||||
|
setCustomHeaders={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const envButton = screen.getByText("Environment Variables");
|
||||||
|
expect(envButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows configuration section", () => {
|
||||||
|
render(
|
||||||
|
<Sidebar
|
||||||
|
{...defaultProps}
|
||||||
|
config={DEFAULT_INSPECTOR_CONFIG}
|
||||||
|
customHeaders={[]}
|
||||||
|
setCustomHeaders={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const configButton = screen.getByText("Configuration");
|
||||||
|
expect(configButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
import { describe, it, expect, jest } from "@jest/globals";
|
import { describe, it, expect, jest } from "@jest/globals";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import ToolsTab from "../ToolsTab";
|
import ToolsTab from "../ToolsTab";
|
||||||
@@ -43,7 +43,7 @@ describe("ToolsTab", () => {
|
|||||||
tools: mockTools,
|
tools: mockTools,
|
||||||
listTools: jest.fn(),
|
listTools: jest.fn(),
|
||||||
clearTools: jest.fn(),
|
clearTools: jest.fn(),
|
||||||
callTool: jest.fn(),
|
callTool: jest.fn(async () => {}),
|
||||||
selectedTool: null,
|
selectedTool: null,
|
||||||
setSelectedTool: jest.fn(),
|
setSelectedTool: jest.fn(),
|
||||||
toolResult: null,
|
toolResult: null,
|
||||||
@@ -59,14 +59,16 @@ describe("ToolsTab", () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should reset input values when switching tools", () => {
|
it("should reset input values when switching tools", async () => {
|
||||||
const { rerender } = renderToolsTab({
|
const { rerender } = renderToolsTab({
|
||||||
selectedTool: mockTools[0],
|
selectedTool: mockTools[0],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enter a value in the first tool's input
|
// Enter a value in the first tool's input
|
||||||
const input = screen.getByRole("spinbutton") as HTMLInputElement;
|
const input = screen.getByRole("spinbutton") as HTMLInputElement;
|
||||||
|
await act(async () => {
|
||||||
fireEvent.change(input, { target: { value: "42" } });
|
fireEvent.change(input, { target: { value: "42" } });
|
||||||
|
});
|
||||||
expect(input.value).toBe("42");
|
expect(input.value).toBe("42");
|
||||||
|
|
||||||
// Switch to second tool
|
// Switch to second tool
|
||||||
@@ -80,7 +82,8 @@ describe("ToolsTab", () => {
|
|||||||
const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
|
const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
|
||||||
expect(newInput.value).toBe("");
|
expect(newInput.value).toBe("");
|
||||||
});
|
});
|
||||||
it("should handle integer type inputs", () => {
|
|
||||||
|
it("should handle integer type inputs", async () => {
|
||||||
renderToolsTab({
|
renderToolsTab({
|
||||||
selectedTool: mockTools[1], // Use the tool with integer type
|
selectedTool: mockTools[1], // Use the tool with integer type
|
||||||
});
|
});
|
||||||
@@ -93,10 +96,49 @@ describe("ToolsTab", () => {
|
|||||||
expect(input.value).toBe("42");
|
expect(input.value).toBe("42");
|
||||||
|
|
||||||
const submitButton = screen.getByRole("button", { name: /run tool/i });
|
const submitButton = screen.getByRole("button", { name: /run tool/i });
|
||||||
|
await act(async () => {
|
||||||
fireEvent.click(submitButton);
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
|
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
|
||||||
count: 42,
|
count: 42,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should disable button and change text while tool is running", async () => {
|
||||||
|
// Create a promise that we can resolve later
|
||||||
|
let resolvePromise: ((value: unknown) => void) | undefined;
|
||||||
|
const mockPromise = new Promise((resolve) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock callTool to return our promise
|
||||||
|
const mockCallTool = jest.fn().mockReturnValue(mockPromise);
|
||||||
|
|
||||||
|
renderToolsTab({
|
||||||
|
selectedTool: mockTools[0],
|
||||||
|
callTool: mockCallTool,
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole("button", { name: /run tool/i });
|
||||||
|
expect(submitButton.getAttribute("disabled")).toBeNull();
|
||||||
|
|
||||||
|
// Click the button and verify immediate state changes
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify button is disabled and text changed
|
||||||
|
expect(submitButton.getAttribute("disabled")).not.toBeNull();
|
||||||
|
expect(submitButton.textContent).toBe("Running...");
|
||||||
|
|
||||||
|
// Resolve the promise to simulate tool completion
|
||||||
|
await act(async () => {
|
||||||
|
if (resolvePromise) {
|
||||||
|
await resolvePromise({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(submitButton.getAttribute("disabled")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
73
client/src/components/__tests__/samplingRequest.test.tsx
Normal file
73
client/src/components/__tests__/samplingRequest.test.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import SamplingRequest from "../SamplingRequest";
|
||||||
|
import { PendingRequest } from "../SamplingTab";
|
||||||
|
|
||||||
|
const mockRequest: PendingRequest = {
|
||||||
|
id: 1,
|
||||||
|
request: {
|
||||||
|
method: "sampling/createMessage",
|
||||||
|
params: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: {
|
||||||
|
type: "text",
|
||||||
|
text: "What files are in the current directory?",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
systemPrompt: "You are a helpful file system assistant.",
|
||||||
|
includeContext: "thisServer",
|
||||||
|
maxTokens: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Form to handle sampling response", () => {
|
||||||
|
const mockOnApprove = jest.fn();
|
||||||
|
const mockOnReject = jest.fn();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onApprove with correct text content when Approve button is clicked", () => {
|
||||||
|
render(
|
||||||
|
<SamplingRequest
|
||||||
|
request={mockRequest}
|
||||||
|
onApprove={mockOnApprove}
|
||||||
|
onReject={mockOnReject}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click the Approve button
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
|
||||||
|
|
||||||
|
// Assert that onApprove is called with the correct arguments
|
||||||
|
expect(mockOnApprove).toHaveBeenCalledWith(mockRequest.id, {
|
||||||
|
model: "stub-model",
|
||||||
|
stopReason: "endTurn",
|
||||||
|
role: "assistant",
|
||||||
|
content: {
|
||||||
|
type: "text",
|
||||||
|
text: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onReject with correct request id when Reject button is clicked", () => {
|
||||||
|
render(
|
||||||
|
<SamplingRequest
|
||||||
|
request={mockRequest}
|
||||||
|
onApprove={mockOnApprove}
|
||||||
|
onReject={mockOnReject}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click the Approve button
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Reject/i }));
|
||||||
|
|
||||||
|
// Assert that onApprove is called with the correct arguments
|
||||||
|
expect(mockOnReject).toHaveBeenCalledWith(mockRequest.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
client/src/components/__tests__/samplingTab.test.tsx
Normal file
55
client/src/components/__tests__/samplingTab.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
|
import SamplingTab, { PendingRequest } from "../SamplingTab";
|
||||||
|
|
||||||
|
describe("Sampling tab", () => {
|
||||||
|
const mockOnApprove = jest.fn();
|
||||||
|
const mockOnReject = jest.fn();
|
||||||
|
|
||||||
|
const renderSamplingTab = (pendingRequests: PendingRequest[]) =>
|
||||||
|
render(
|
||||||
|
<Tabs defaultValue="sampling">
|
||||||
|
<SamplingTab
|
||||||
|
pendingRequests={pendingRequests}
|
||||||
|
onApprove={mockOnApprove}
|
||||||
|
onReject={mockOnReject}
|
||||||
|
/>
|
||||||
|
</Tabs>,
|
||||||
|
);
|
||||||
|
|
||||||
|
it("should render 'No pending requests' when there are no pending requests", () => {
|
||||||
|
renderSamplingTab([]);
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"When the server requests LLM sampling, requests will appear here for approval.",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(screen.findByText("No pending requests")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the correct number of requests", () => {
|
||||||
|
renderSamplingTab(
|
||||||
|
Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
request: {
|
||||||
|
method: "sampling/createMessage",
|
||||||
|
params: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: {
|
||||||
|
type: "text",
|
||||||
|
text: "What files are in the current directory?",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
systemPrompt: "You are a helpful file system assistant.",
|
||||||
|
includeContext: "thisServer",
|
||||||
|
maxTokens: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
expect(screen.getAllByTestId("sampling-request").length).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,4 +54,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
);
|
);
|
||||||
Button.displayName = "Button";
|
Button.displayName = "Button";
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
export { Button };
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import * as React from "react";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import * as React from "react";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface TextareaProps
|
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
|
||||||
|
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
({ className, ...props }, ref) => {
|
({ className, ...props }, ref) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/lib/hooks/useToast";
|
||||||
import {
|
import {
|
||||||
Toast,
|
Toast,
|
||||||
ToastClose,
|
ToastClose,
|
||||||
|
|||||||
38
client/src/lib/auth-types.ts
Normal file
38
client/src/lib/auth-types.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
OAuthMetadata,
|
||||||
|
OAuthClientInformationFull,
|
||||||
|
OAuthClientInformation,
|
||||||
|
OAuthTokens,
|
||||||
|
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
|
||||||
|
// OAuth flow steps
|
||||||
|
export type OAuthStep =
|
||||||
|
| "metadata_discovery"
|
||||||
|
| "client_registration"
|
||||||
|
| "authorization_redirect"
|
||||||
|
| "authorization_code"
|
||||||
|
| "token_request"
|
||||||
|
| "complete";
|
||||||
|
|
||||||
|
// Message types for inline feedback
|
||||||
|
export type MessageType = "success" | "error" | "info";
|
||||||
|
|
||||||
|
export interface StatusMessage {
|
||||||
|
type: MessageType;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single state interface for OAuth state
|
||||||
|
export interface AuthDebuggerState {
|
||||||
|
isInitiatingAuth: boolean;
|
||||||
|
oauthTokens: OAuthTokens | null;
|
||||||
|
loading: boolean;
|
||||||
|
oauthStep: OAuthStep;
|
||||||
|
oauthMetadata: OAuthMetadata | null;
|
||||||
|
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
|
||||||
|
authorizationUrl: string | null;
|
||||||
|
authorizationCode: string;
|
||||||
|
latestError: Error | null;
|
||||||
|
statusMessage: StatusMessage | null;
|
||||||
|
validationError: string | null;
|
||||||
|
}
|
||||||
@@ -4,15 +4,22 @@ import {
|
|||||||
OAuthClientInformation,
|
OAuthClientInformation,
|
||||||
OAuthTokens,
|
OAuthTokens,
|
||||||
OAuthTokensSchema,
|
OAuthTokensSchema,
|
||||||
|
OAuthClientMetadata,
|
||||||
|
OAuthMetadata,
|
||||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
import { SESSION_KEYS } from "./constants";
|
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
|
||||||
|
|
||||||
|
export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||||
|
constructor(public serverUrl: string) {
|
||||||
|
// Save the server URL to session storage
|
||||||
|
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|
||||||
get redirectUrl() {
|
get redirectUrl() {
|
||||||
return window.location.origin + "/oauth/callback";
|
return window.location.origin + "/oauth/callback";
|
||||||
}
|
}
|
||||||
|
|
||||||
get clientMetadata() {
|
get clientMetadata(): OAuthClientMetadata {
|
||||||
return {
|
return {
|
||||||
redirect_uris: [this.redirectUrl],
|
redirect_uris: [this.redirectUrl],
|
||||||
token_endpoint_auth_method: "none",
|
token_endpoint_auth_method: "none",
|
||||||
@@ -24,7 +31,11 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async clientInformation() {
|
async clientInformation() {
|
||||||
const value = sessionStorage.getItem(SESSION_KEYS.CLIENT_INFORMATION);
|
const key = getServerSpecificKey(
|
||||||
|
SESSION_KEYS.CLIENT_INFORMATION,
|
||||||
|
this.serverUrl,
|
||||||
|
);
|
||||||
|
const value = sessionStorage.getItem(key);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -33,14 +44,16 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveClientInformation(clientInformation: OAuthClientInformation) {
|
saveClientInformation(clientInformation: OAuthClientInformation) {
|
||||||
sessionStorage.setItem(
|
const key = getServerSpecificKey(
|
||||||
SESSION_KEYS.CLIENT_INFORMATION,
|
SESSION_KEYS.CLIENT_INFORMATION,
|
||||||
JSON.stringify(clientInformation),
|
this.serverUrl,
|
||||||
);
|
);
|
||||||
|
sessionStorage.setItem(key, JSON.stringify(clientInformation));
|
||||||
}
|
}
|
||||||
|
|
||||||
async tokens() {
|
async tokens() {
|
||||||
const tokens = sessionStorage.getItem(SESSION_KEYS.TOKENS);
|
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
|
||||||
|
const tokens = sessionStorage.getItem(key);
|
||||||
if (!tokens) {
|
if (!tokens) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -49,7 +62,8 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveTokens(tokens: OAuthTokens) {
|
saveTokens(tokens: OAuthTokens) {
|
||||||
sessionStorage.setItem(SESSION_KEYS.TOKENS, JSON.stringify(tokens));
|
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
|
||||||
|
sessionStorage.setItem(key, JSON.stringify(tokens));
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToAuthorization(authorizationUrl: URL) {
|
redirectToAuthorization(authorizationUrl: URL) {
|
||||||
@@ -57,17 +71,70 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveCodeVerifier(codeVerifier: string) {
|
saveCodeVerifier(codeVerifier: string) {
|
||||||
sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier);
|
const key = getServerSpecificKey(
|
||||||
|
SESSION_KEYS.CODE_VERIFIER,
|
||||||
|
this.serverUrl,
|
||||||
|
);
|
||||||
|
sessionStorage.setItem(key, codeVerifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
codeVerifier() {
|
codeVerifier() {
|
||||||
const verifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER);
|
const key = getServerSpecificKey(
|
||||||
|
SESSION_KEYS.CODE_VERIFIER,
|
||||||
|
this.serverUrl,
|
||||||
|
);
|
||||||
|
const verifier = sessionStorage.getItem(key);
|
||||||
if (!verifier) {
|
if (!verifier) {
|
||||||
throw new Error("No code verifier saved for session");
|
throw new Error("No code verifier saved for session");
|
||||||
}
|
}
|
||||||
|
|
||||||
return verifier;
|
return verifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
sessionStorage.removeItem(
|
||||||
|
getServerSpecificKey(SESSION_KEYS.CLIENT_INFORMATION, this.serverUrl),
|
||||||
|
);
|
||||||
|
sessionStorage.removeItem(
|
||||||
|
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
|
||||||
|
);
|
||||||
|
sessionStorage.removeItem(
|
||||||
|
getServerSpecificKey(SESSION_KEYS.CODE_VERIFIER, this.serverUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authProvider = new InspectorOAuthClientProvider();
|
// Overrides debug URL and allows saving server OAuth metadata to
|
||||||
|
// display in debug UI.
|
||||||
|
export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider {
|
||||||
|
get redirectUrl(): string {
|
||||||
|
return `${window.location.origin}/oauth/callback/debug`;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveServerMetadata(metadata: OAuthMetadata) {
|
||||||
|
const key = getServerSpecificKey(
|
||||||
|
SESSION_KEYS.SERVER_METADATA,
|
||||||
|
this.serverUrl,
|
||||||
|
);
|
||||||
|
sessionStorage.setItem(key, JSON.stringify(metadata));
|
||||||
|
}
|
||||||
|
|
||||||
|
getServerMetadata(): OAuthMetadata | null {
|
||||||
|
const key = getServerSpecificKey(
|
||||||
|
SESSION_KEYS.SERVER_METADATA,
|
||||||
|
this.serverUrl,
|
||||||
|
);
|
||||||
|
const metadata = sessionStorage.getItem(key);
|
||||||
|
if (!metadata) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
super.clear();
|
||||||
|
sessionStorage.removeItem(
|
||||||
|
getServerSpecificKey(SESSION_KEYS.SERVER_METADATA, this.serverUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type ConfigItem = {
|
export type ConfigItem = {
|
||||||
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
value: string | number | boolean;
|
value: string | number | boolean;
|
||||||
};
|
};
|
||||||
@@ -15,5 +16,21 @@ export type InspectorConfig = {
|
|||||||
* Maximum time in milliseconds to wait for a response from the MCP server before timing out.
|
* Maximum time in milliseconds to wait for a response from the MCP server before timing out.
|
||||||
*/
|
*/
|
||||||
MCP_SERVER_REQUEST_TIMEOUT: ConfigItem;
|
MCP_SERVER_REQUEST_TIMEOUT: ConfigItem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to reset the timeout on progress notifications. Useful for long-running operations that send periodic progress updates.
|
||||||
|
* Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow
|
||||||
|
*/
|
||||||
|
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: ConfigItem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum total time in milliseconds to wait for a response from the MCP server before timing out. Used in conjunction with MCP_SERVER_REQUEST_TIMEOUT_RESET_ON_PROGRESS.
|
||||||
|
* Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow
|
||||||
|
*/
|
||||||
|
MCP_REQUEST_MAX_TOTAL_TIMEOUT: ConfigItem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full address of the MCP Proxy Server, in case it is running on a non-default address. Example: http://10.1.1.22:5577
|
||||||
|
*/
|
||||||
MCP_PROXY_FULL_ADDRESS: ConfigItem;
|
MCP_PROXY_FULL_ADDRESS: ConfigItem;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,18 @@ export const SESSION_KEYS = {
|
|||||||
SERVER_URL: "mcp_server_url",
|
SERVER_URL: "mcp_server_url",
|
||||||
TOKENS: "mcp_tokens",
|
TOKENS: "mcp_tokens",
|
||||||
CLIENT_INFORMATION: "mcp_client_information",
|
CLIENT_INFORMATION: "mcp_client_information",
|
||||||
|
SERVER_METADATA: "mcp_server_metadata",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// Generate server-specific session storage keys
|
||||||
|
export const getServerSpecificKey = (
|
||||||
|
baseKey: string,
|
||||||
|
serverUrl?: string,
|
||||||
|
): string => {
|
||||||
|
if (!serverUrl) return baseKey;
|
||||||
|
return `[${serverUrl}] ${baseKey}`;
|
||||||
|
};
|
||||||
|
|
||||||
export type ConnectionStatus =
|
export type ConnectionStatus =
|
||||||
| "disconnected"
|
| "disconnected"
|
||||||
| "connected"
|
| "connected"
|
||||||
@@ -22,10 +32,23 @@ export const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
|
|||||||
**/
|
**/
|
||||||
export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
|
export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
|
||||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
label: "Request Timeout",
|
||||||
description: "Timeout for requests to the MCP server (ms)",
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
value: 10000,
|
value: 10000,
|
||||||
},
|
},
|
||||||
|
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: {
|
||||||
|
label: "Reset Timeout on Progress",
|
||||||
|
description: "Reset timeout on progress notifications",
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
|
||||||
|
label: "Maximum Total Timeout",
|
||||||
|
description:
|
||||||
|
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
|
||||||
|
value: 60000,
|
||||||
|
},
|
||||||
MCP_PROXY_FULL_ADDRESS: {
|
MCP_PROXY_FULL_ADDRESS: {
|
||||||
|
label: "Inspector Proxy Address",
|
||||||
description:
|
description:
|
||||||
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
|
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
|
||||||
value: "",
|
value: "",
|
||||||
|
|||||||
166
client/src/lib/hooks/__tests__/useConnection.test.tsx
Normal file
166
client/src/lib/hooks/__tests__/useConnection.test.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { useConnection } from "../useConnection";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
global.fetch = jest.fn().mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ status: "ok" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the SDK dependencies
|
||||||
|
const mockRequest = jest.fn().mockResolvedValue({ test: "response" });
|
||||||
|
const mockClient = {
|
||||||
|
request: mockRequest,
|
||||||
|
notification: jest.fn(),
|
||||||
|
connect: jest.fn().mockResolvedValue(undefined),
|
||||||
|
close: jest.fn(),
|
||||||
|
getServerCapabilities: jest.fn(),
|
||||||
|
getServerVersion: jest.fn(),
|
||||||
|
getInstructions: jest.fn(),
|
||||||
|
setNotificationHandler: jest.fn(),
|
||||||
|
setRequestHandler: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||||
|
Client: jest.fn().mockImplementation(() => mockClient),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
||||||
|
SSEClientTransport: jest.fn(),
|
||||||
|
SseError: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||||
|
auth: jest.fn().mockResolvedValue("AUTHORIZED"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the toast hook
|
||||||
|
jest.mock("@/lib/hooks/useToast", () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
toast: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the auth provider
|
||||||
|
jest.mock("../../auth", () => ({
|
||||||
|
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
|
||||||
|
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useConnection", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
transportType: "sse" as const,
|
||||||
|
command: "",
|
||||||
|
args: "",
|
||||||
|
sseUrl: "http://localhost:8080",
|
||||||
|
env: {},
|
||||||
|
config: DEFAULT_INSPECTOR_CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Request Configuration", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the default config values in makeRequest", async () => {
|
||||||
|
const { result } = renderHook(() => useConnection(defaultProps));
|
||||||
|
|
||||||
|
// Connect the client
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for state update
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest: ClientRequest = {
|
||||||
|
method: "ping",
|
||||||
|
params: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSchema = z.object({
|
||||||
|
test: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.makeRequest(mockRequest, mockSchema);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.request).toHaveBeenCalledWith(
|
||||||
|
mockRequest,
|
||||||
|
mockSchema,
|
||||||
|
expect.objectContaining({
|
||||||
|
timeout: DEFAULT_INSPECTOR_CONFIG.MCP_SERVER_REQUEST_TIMEOUT.value,
|
||||||
|
maxTotalTimeout:
|
||||||
|
DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value,
|
||||||
|
resetTimeoutOnProgress:
|
||||||
|
DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS
|
||||||
|
.value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overrides the default config values when passed in options in makeRequest", async () => {
|
||||||
|
const { result } = renderHook(() => useConnection(defaultProps));
|
||||||
|
|
||||||
|
// Connect the client
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for state update
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest: ClientRequest = {
|
||||||
|
method: "ping",
|
||||||
|
params: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSchema = z.object({
|
||||||
|
test: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.makeRequest(mockRequest, mockSchema, {
|
||||||
|
timeout: 1000,
|
||||||
|
maxTotalTimeout: 2000,
|
||||||
|
resetTimeoutOnProgress: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.request).toHaveBeenCalledWith(
|
||||||
|
mockRequest,
|
||||||
|
mockSchema,
|
||||||
|
expect.objectContaining({
|
||||||
|
timeout: 1000,
|
||||||
|
maxTotalTimeout: 2000,
|
||||||
|
resetTimeoutOnProgress: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws error when mcpClient is not connected", async () => {
|
||||||
|
const { result } = renderHook(() => useConnection(defaultProps));
|
||||||
|
|
||||||
|
const mockRequest: ClientRequest = {
|
||||||
|
method: "ping",
|
||||||
|
params: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSchema = z.object({
|
||||||
|
test: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
result.current.makeRequest(mockRequest, mockSchema),
|
||||||
|
).rejects.toThrow("MCP client not connected");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ResourceReference,
|
ResourceReference,
|
||||||
PromptReference,
|
PromptReference,
|
||||||
@@ -15,9 +15,11 @@ function debounce<T extends (...args: any[]) => PromiseLike<void>>(
|
|||||||
wait: number,
|
wait: number,
|
||||||
): (...args: Parameters<T>) => void {
|
): (...args: Parameters<T>) => void {
|
||||||
let timeout: ReturnType<typeof setTimeout>;
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
return function (...args: Parameters<T>) {
|
return (...args: Parameters<T>) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
timeout = setTimeout(() => func(...args), wait);
|
timeout = setTimeout(() => {
|
||||||
|
void func(...args);
|
||||||
|
}, wait);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,8 +60,8 @@ export function useCompletionState(
|
|||||||
});
|
});
|
||||||
}, [cleanup]);
|
}, [cleanup]);
|
||||||
|
|
||||||
const requestCompletions = useCallback(
|
const requestCompletions = useMemo(() => {
|
||||||
debounce(
|
return debounce(
|
||||||
async (
|
async (
|
||||||
ref: ResourceReference | PromptReference,
|
ref: ResourceReference | PromptReference,
|
||||||
argName: string,
|
argName: string,
|
||||||
@@ -94,7 +96,7 @@ export function useCompletionState(
|
|||||||
loading: { ...prev.loading, [argName]: false },
|
loading: { ...prev.loading, [argName]: false },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -108,9 +110,8 @@ export function useCompletionState(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
debounceMs,
|
debounceMs,
|
||||||
),
|
|
||||||
[handleCompletion, completionsSupported, cleanup, debounceMs],
|
|
||||||
);
|
);
|
||||||
|
}, [handleCompletion, completionsSupported, cleanup, debounceMs]);
|
||||||
|
|
||||||
// Clear completions when support status changes
|
// Clear completions when support status changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|||||||
import {
|
import {
|
||||||
SSEClientTransport,
|
SSEClientTransport,
|
||||||
SseError,
|
SseError,
|
||||||
|
SSEClientTransportOptions,
|
||||||
} from "@modelcontextprotocol/sdk/client/sse.js";
|
} from "@modelcontextprotocol/sdk/client/sse.js";
|
||||||
|
import {
|
||||||
|
StreamableHTTPClientTransport,
|
||||||
|
StreamableHTTPClientTransportOptions,
|
||||||
|
} from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
import {
|
import {
|
||||||
ClientNotification,
|
ClientNotification,
|
||||||
ClientRequest,
|
ClientRequest,
|
||||||
CreateMessageRequestSchema,
|
CreateMessageRequestSchema,
|
||||||
ListRootsRequestSchema,
|
ListRootsRequestSchema,
|
||||||
ProgressNotificationSchema,
|
|
||||||
ResourceUpdatedNotificationSchema,
|
ResourceUpdatedNotificationSchema,
|
||||||
LoggingMessageNotificationSchema,
|
LoggingMessageNotificationSchema,
|
||||||
Request,
|
Request,
|
||||||
@@ -23,25 +27,35 @@ import {
|
|||||||
ResourceListChangedNotificationSchema,
|
ResourceListChangedNotificationSchema,
|
||||||
ToolListChangedNotificationSchema,
|
ToolListChangedNotificationSchema,
|
||||||
PromptListChangedNotificationSchema,
|
PromptListChangedNotificationSchema,
|
||||||
|
Progress,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/lib/hooks/useToast";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ConnectionStatus, SESSION_KEYS } from "../constants";
|
import { ConnectionStatus } from "../constants";
|
||||||
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
||||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
import { authProvider } from "../auth";
|
import { InspectorOAuthClientProvider } from "../auth";
|
||||||
import packageJson from "../../../package.json";
|
import packageJson from "../../../package.json";
|
||||||
|
import {
|
||||||
|
getMCPProxyAddress,
|
||||||
|
getMCPServerRequestMaxTotalTimeout,
|
||||||
|
resetRequestTimeoutOnProgress,
|
||||||
|
} from "@/utils/configUtils";
|
||||||
|
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
|
||||||
|
import { InspectorConfig } from "../configurationTypes";
|
||||||
|
|
||||||
interface UseConnectionOptions {
|
interface UseConnectionOptions {
|
||||||
transportType: "stdio" | "sse";
|
transportType: "stdio" | "sse" | "streamable-http";
|
||||||
command: string;
|
command: string;
|
||||||
args: string;
|
args: string;
|
||||||
sseUrl: string;
|
sseUrl: string;
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
proxyServerUrl: string;
|
|
||||||
bearerToken?: string;
|
bearerToken?: string;
|
||||||
requestTimeout?: number;
|
headerName?: string;
|
||||||
|
customHeaders?: [string, string][];
|
||||||
|
config: InspectorConfig;
|
||||||
onNotification?: (notification: Notification) => void;
|
onNotification?: (notification: Notification) => void;
|
||||||
onStdErrNotification?: (notification: Notification) => void;
|
onStdErrNotification?: (notification: Notification) => void;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -50,21 +64,16 @@ interface UseConnectionOptions {
|
|||||||
getRoots?: () => any[];
|
getRoots?: () => any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RequestOptions {
|
|
||||||
signal?: AbortSignal;
|
|
||||||
timeout?: number;
|
|
||||||
suppressToast?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useConnection({
|
export function useConnection({
|
||||||
transportType,
|
transportType,
|
||||||
command,
|
command,
|
||||||
args,
|
args,
|
||||||
sseUrl,
|
sseUrl,
|
||||||
env,
|
env,
|
||||||
proxyServerUrl,
|
|
||||||
bearerToken,
|
bearerToken,
|
||||||
requestTimeout,
|
headerName,
|
||||||
|
customHeaders,
|
||||||
|
config,
|
||||||
onNotification,
|
onNotification,
|
||||||
onStdErrNotification,
|
onStdErrNotification,
|
||||||
onPendingRequest,
|
onPendingRequest,
|
||||||
@@ -94,31 +103,50 @@ export function useConnection({
|
|||||||
const makeRequest = async <T extends z.ZodType>(
|
const makeRequest = async <T extends z.ZodType>(
|
||||||
request: ClientRequest,
|
request: ClientRequest,
|
||||||
schema: T,
|
schema: T,
|
||||||
options?: RequestOptions,
|
options?: RequestOptions & { suppressToast?: boolean },
|
||||||
): Promise<z.output<T>> => {
|
): Promise<z.output<T>> => {
|
||||||
if (!mcpClient) {
|
if (!mcpClient) {
|
||||||
throw new Error("MCP client not connected");
|
throw new Error("MCP client not connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
abortController.abort("Request timed out");
|
// prepare MCP Client request options
|
||||||
}, options?.timeout ?? requestTimeout);
|
const mcpRequestOptions: RequestOptions = {
|
||||||
|
signal: options?.signal ?? abortController.signal,
|
||||||
|
resetTimeoutOnProgress:
|
||||||
|
options?.resetTimeoutOnProgress ??
|
||||||
|
resetRequestTimeoutOnProgress(config),
|
||||||
|
timeout: options?.timeout ?? getMCPServerRequestTimeout(config),
|
||||||
|
maxTotalTimeout:
|
||||||
|
options?.maxTotalTimeout ??
|
||||||
|
getMCPServerRequestMaxTotalTimeout(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
// If progress notifications are enabled, add an onprogress hook to the MCP Client request options
|
||||||
|
// This is required by SDK to reset the timeout on progress notifications
|
||||||
|
if (mcpRequestOptions.resetTimeoutOnProgress) {
|
||||||
|
mcpRequestOptions.onprogress = (params: Progress) => {
|
||||||
|
// Add progress notification to `Server Notification` window in the UI
|
||||||
|
if (onNotification) {
|
||||||
|
onNotification({
|
||||||
|
method: "notification/progress",
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await mcpClient.request(request, schema, {
|
response = await mcpClient.request(request, schema, mcpRequestOptions);
|
||||||
signal: options?.signal ?? abortController.signal,
|
|
||||||
});
|
|
||||||
pushHistory(request, response);
|
pushHistory(request, response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? error.message : String(error);
|
||||||
pushHistory(request, { error: errorMessage });
|
pushHistory(request, { error: errorMessage });
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -211,7 +239,7 @@ export function useConnection({
|
|||||||
|
|
||||||
const checkProxyHealth = async () => {
|
const checkProxyHealth = async () => {
|
||||||
try {
|
try {
|
||||||
const proxyHealthUrl = new URL(`${proxyServerUrl}/health`);
|
const proxyHealthUrl = new URL(`${getMCPProxyAddress(config)}/health`);
|
||||||
const proxyHealthResponse = await fetch(proxyHealthUrl);
|
const proxyHealthResponse = await fetch(proxyHealthUrl);
|
||||||
const proxyHealth = await proxyHealthResponse.json();
|
const proxyHealth = await proxyHealthResponse.json();
|
||||||
if (proxyHealth?.status !== "ok") {
|
if (proxyHealth?.status !== "ok") {
|
||||||
@@ -225,9 +253,10 @@ export function useConnection({
|
|||||||
|
|
||||||
const handleAuthError = async (error: unknown) => {
|
const handleAuthError = async (error: unknown) => {
|
||||||
if (error instanceof SseError && error.code === 401) {
|
if (error instanceof SseError && error.code === 401) {
|
||||||
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl);
|
// Create a new auth provider with the current server URL
|
||||||
|
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||||
|
|
||||||
const result = await auth(authProvider, { serverUrl: sseUrl });
|
const result = await auth(serverAuthProvider, { serverUrl: sseUrl });
|
||||||
return result === "AUTHORIZED";
|
return result === "AUTHORIZED";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,40 +285,108 @@ export function useConnection({
|
|||||||
setConnectionStatus("error-connecting-to-proxy");
|
setConnectionStatus("error-connecting-to-proxy");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const mcpProxyServerUrl = new URL(`${proxyServerUrl}/sse`);
|
|
||||||
mcpProxyServerUrl.searchParams.append("transportType", transportType);
|
|
||||||
if (transportType === "stdio") {
|
|
||||||
mcpProxyServerUrl.searchParams.append("command", command);
|
|
||||||
mcpProxyServerUrl.searchParams.append("args", args);
|
|
||||||
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
|
|
||||||
} else {
|
|
||||||
mcpProxyServerUrl.searchParams.append("url", sseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Inject auth manually instead of using SSEClientTransport, because we're
|
// Inject auth manually instead of using SSEClientTransport, because we're
|
||||||
// proxying through the inspector server first.
|
// proxying through the inspector server first.
|
||||||
const headers: HeadersInit = {};
|
const headers = new Headers(customHeaders||[]);
|
||||||
|
|
||||||
|
//const headers: HeadersInit = [ ...customHeaders||[] ];
|
||||||
|
|
||||||
|
// Create an auth provider with the current server URL
|
||||||
|
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||||
|
|
||||||
// Use manually provided bearer token if available, otherwise use OAuth tokens
|
// Use manually provided bearer token if available, otherwise use OAuth tokens
|
||||||
const token = bearerToken || (await authProvider.tokens())?.access_token;
|
const token =
|
||||||
|
bearerToken || (await serverAuthProvider.tokens())?.access_token;
|
||||||
if (token) {
|
if (token) {
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
const authHeaderName = headerName || "Authorization";
|
||||||
|
headers.set(authHeaderName, `Bearer ${token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientTransport = new SSEClientTransport(mcpProxyServerUrl, {
|
// Create appropriate transport
|
||||||
|
let transportOptions:
|
||||||
|
| StreamableHTTPClientTransportOptions
|
||||||
|
| SSEClientTransportOptions;
|
||||||
|
|
||||||
|
let mcpProxyServerUrl;
|
||||||
|
switch (transportType) {
|
||||||
|
case "stdio":
|
||||||
|
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`);
|
||||||
|
mcpProxyServerUrl.searchParams.append("command", command);
|
||||||
|
mcpProxyServerUrl.searchParams.append("args", args);
|
||||||
|
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
|
||||||
|
transportOptions = {
|
||||||
|
authProvider: serverAuthProvider,
|
||||||
eventSourceInit: {
|
eventSourceInit: {
|
||||||
fetch: (url, init) => fetch(url, { ...init, headers }),
|
fetch: (
|
||||||
|
url: string | URL | globalThis.Request,
|
||||||
|
init: RequestInit | undefined,
|
||||||
|
) => fetch(url, { ...init, headers }),
|
||||||
},
|
},
|
||||||
requestInit: {
|
requestInit: {
|
||||||
headers,
|
headers,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "sse":
|
||||||
|
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
|
||||||
|
mcpProxyServerUrl.searchParams.append("url", sseUrl);
|
||||||
|
transportOptions = {
|
||||||
|
authProvider: serverAuthProvider,
|
||||||
|
eventSourceInit: {
|
||||||
|
fetch: (
|
||||||
|
url: string | URL | globalThis.Request,
|
||||||
|
init: RequestInit | undefined,
|
||||||
|
) => fetch(url, { ...init, headers }),
|
||||||
|
},
|
||||||
|
requestInit: {
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "streamable-http":
|
||||||
|
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
|
||||||
|
mcpProxyServerUrl.searchParams.append("url", sseUrl);
|
||||||
|
transportOptions = {
|
||||||
|
authProvider: serverAuthProvider,
|
||||||
|
eventSourceInit: {
|
||||||
|
fetch: (
|
||||||
|
url: string | URL | globalThis.Request,
|
||||||
|
init: RequestInit | undefined,
|
||||||
|
) => fetch(url, { ...init, headers }),
|
||||||
|
},
|
||||||
|
requestInit: {
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
// TODO these should be configurable...
|
||||||
|
reconnectionOptions: {
|
||||||
|
maxReconnectionDelay: 30000,
|
||||||
|
initialReconnectionDelay: 1000,
|
||||||
|
reconnectionDelayGrowFactor: 1.5,
|
||||||
|
maxRetries: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
(mcpProxyServerUrl as URL).searchParams.append(
|
||||||
|
"transportType",
|
||||||
|
transportType,
|
||||||
|
);
|
||||||
|
|
||||||
|
const clientTransport =
|
||||||
|
transportType === "streamable-http"
|
||||||
|
? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
|
||||||
|
sessionId: undefined,
|
||||||
|
...transportOptions,
|
||||||
|
})
|
||||||
|
: new SSEClientTransport(mcpProxyServerUrl as URL, transportOptions);
|
||||||
|
|
||||||
if (onNotification) {
|
if (onNotification) {
|
||||||
[
|
[
|
||||||
CancelledNotificationSchema,
|
CancelledNotificationSchema,
|
||||||
ProgressNotificationSchema,
|
|
||||||
LoggingMessageNotificationSchema,
|
LoggingMessageNotificationSchema,
|
||||||
ResourceUpdatedNotificationSchema,
|
ResourceUpdatedNotificationSchema,
|
||||||
ResourceListChangedNotificationSchema,
|
ResourceListChangedNotificationSchema,
|
||||||
@@ -314,8 +411,19 @@ export function useConnection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let capabilities;
|
||||||
try {
|
try {
|
||||||
await client.connect(clientTransport);
|
await client.connect(clientTransport);
|
||||||
|
|
||||||
|
capabilities = client.getServerCapabilities();
|
||||||
|
const initializeRequest = {
|
||||||
|
method: "initialize",
|
||||||
|
};
|
||||||
|
pushHistory(initializeRequest, {
|
||||||
|
capabilities,
|
||||||
|
serverInfo: client.getServerVersion(),
|
||||||
|
instructions: client.getInstructions(),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`Failed to connect to MCP Server via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`,
|
`Failed to connect to MCP Server via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`,
|
||||||
@@ -332,8 +440,6 @@ export function useConnection({
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const capabilities = client.getServerCapabilities();
|
|
||||||
setServerCapabilities(capabilities ?? null);
|
setServerCapabilities(capabilities ?? null);
|
||||||
setCompletionsSupported(true); // Reset completions support on new connection
|
setCompletionsSupported(true); // Reset completions support on new connection
|
||||||
|
|
||||||
@@ -361,6 +467,8 @@ export function useConnection({
|
|||||||
|
|
||||||
const disconnect = async () => {
|
const disconnect = async () => {
|
||||||
await mcpClient?.close();
|
await mcpClient?.close();
|
||||||
|
const authProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||||
|
authProvider.clear();
|
||||||
setMcpClient(null);
|
setMcpClient(null);
|
||||||
setConnectionStatus("disconnected");
|
setConnectionStatus("disconnected");
|
||||||
setCompletionsSupported(false);
|
setCompletionsSupported(false);
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ const useTheme = (): [Theme, (mode: Theme) => void] => {
|
|||||||
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
return useMemo(() => [theme, setThemeWithSideEffect], [theme]);
|
return useMemo(
|
||||||
|
() => [theme, setThemeWithSideEffect],
|
||||||
|
[theme, setThemeWithSideEffect],
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useTheme;
|
export default useTheme;
|
||||||
@@ -15,13 +15,6 @@ type ToasterToast = ToastProps & {
|
|||||||
action?: ToastActionElement;
|
action?: ToastActionElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionTypes = {
|
|
||||||
ADD_TOAST: "ADD_TOAST",
|
|
||||||
UPDATE_TOAST: "UPDATE_TOAST",
|
|
||||||
DISMISS_TOAST: "DISMISS_TOAST",
|
|
||||||
REMOVE_TOAST: "REMOVE_TOAST",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
function genId() {
|
function genId() {
|
||||||
@@ -29,23 +22,28 @@ function genId() {
|
|||||||
return count.toString();
|
return count.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionType = typeof actionTypes;
|
const enum ActionType {
|
||||||
|
ADD_TOAST = "ADD_TOAST",
|
||||||
|
UPDATE_TOAST = "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST = "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST = "REMOVE_TOAST",
|
||||||
|
}
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| {
|
| {
|
||||||
type: ActionType["ADD_TOAST"];
|
type: ActionType.ADD_TOAST;
|
||||||
toast: ToasterToast;
|
toast: ToasterToast;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["UPDATE_TOAST"];
|
type: ActionType.UPDATE_TOAST;
|
||||||
toast: Partial<ToasterToast>;
|
toast: Partial<ToasterToast>;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["DISMISS_TOAST"];
|
type: ActionType.DISMISS_TOAST;
|
||||||
toastId?: ToasterToast["id"];
|
toastId?: ToasterToast["id"];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["REMOVE_TOAST"];
|
type: ActionType.REMOVE_TOAST;
|
||||||
toastId?: ToasterToast["id"];
|
toastId?: ToasterToast["id"];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,7 +61,7 @@ const addToRemoveQueue = (toastId: string) => {
|
|||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
toastTimeouts.delete(toastId);
|
toastTimeouts.delete(toastId);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "REMOVE_TOAST",
|
type: ActionType.REMOVE_TOAST,
|
||||||
toastId: toastId,
|
toastId: toastId,
|
||||||
});
|
});
|
||||||
}, TOAST_REMOVE_DELAY);
|
}, TOAST_REMOVE_DELAY);
|
||||||
@@ -73,13 +71,13 @@ const addToRemoveQueue = (toastId: string) => {
|
|||||||
|
|
||||||
export const reducer = (state: State, action: Action): State => {
|
export const reducer = (state: State, action: Action): State => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "ADD_TOAST":
|
case ActionType.ADD_TOAST:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
};
|
};
|
||||||
|
|
||||||
case "UPDATE_TOAST":
|
case ActionType.UPDATE_TOAST:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: state.toasts.map((t) =>
|
toasts: state.toasts.map((t) =>
|
||||||
@@ -87,7 +85,7 @@ export const reducer = (state: State, action: Action): State => {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
case "DISMISS_TOAST": {
|
case ActionType.DISMISS_TOAST: {
|
||||||
const { toastId } = action;
|
const { toastId } = action;
|
||||||
|
|
||||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
@@ -112,7 +110,7 @@ export const reducer = (state: State, action: Action): State => {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "REMOVE_TOAST":
|
case ActionType.REMOVE_TOAST:
|
||||||
if (action.toastId === undefined) {
|
if (action.toastId === undefined) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -144,13 +142,14 @@ function toast({ ...props }: Toast) {
|
|||||||
|
|
||||||
const update = (props: ToasterToast) =>
|
const update = (props: ToasterToast) =>
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_TOAST",
|
type: ActionType.UPDATE_TOAST,
|
||||||
toast: { ...props, id },
|
toast: { ...props, id },
|
||||||
});
|
});
|
||||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
const dismiss = () =>
|
||||||
|
dispatch({ type: ActionType.DISMISS_TOAST, toastId: id });
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "ADD_TOAST",
|
type: ActionType.ADD_TOAST,
|
||||||
toast: {
|
toast: {
|
||||||
...props,
|
...props,
|
||||||
id,
|
id,
|
||||||
@@ -184,7 +183,8 @@ function useToast() {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toast,
|
toast,
|
||||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
dismiss: (toastId?: string) =>
|
||||||
|
dispatch({ type: ActionType.DISMISS_TOAST, toastId }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
181
client/src/lib/oauth-state-machine.ts
Normal file
181
client/src/lib/oauth-state-machine.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { OAuthStep, AuthDebuggerState } from "./auth-types";
|
||||||
|
import { DebugInspectorOAuthClientProvider } from "./auth";
|
||||||
|
import {
|
||||||
|
discoverOAuthMetadata,
|
||||||
|
registerClient,
|
||||||
|
startAuthorization,
|
||||||
|
exchangeAuthorization,
|
||||||
|
} from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
|
import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
|
||||||
|
export interface StateMachineContext {
|
||||||
|
state: AuthDebuggerState;
|
||||||
|
serverUrl: string;
|
||||||
|
provider: DebugInspectorOAuthClientProvider;
|
||||||
|
updateState: (updates: Partial<AuthDebuggerState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateTransition {
|
||||||
|
canTransition: (context: StateMachineContext) => Promise<boolean>;
|
||||||
|
execute: (context: StateMachineContext) => Promise<void>;
|
||||||
|
nextStep: OAuthStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State machine transitions
|
||||||
|
export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
||||||
|
metadata_discovery: {
|
||||||
|
canTransition: async () => true,
|
||||||
|
execute: async (context) => {
|
||||||
|
const metadata = await discoverOAuthMetadata(context.serverUrl);
|
||||||
|
if (!metadata) {
|
||||||
|
throw new Error("Failed to discover OAuth metadata");
|
||||||
|
}
|
||||||
|
const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata);
|
||||||
|
context.provider.saveServerMetadata(parsedMetadata);
|
||||||
|
context.updateState({
|
||||||
|
oauthMetadata: parsedMetadata,
|
||||||
|
oauthStep: "client_registration",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
nextStep: "client_registration",
|
||||||
|
},
|
||||||
|
|
||||||
|
client_registration: {
|
||||||
|
canTransition: async (context) => !!context.state.oauthMetadata,
|
||||||
|
execute: async (context) => {
|
||||||
|
const metadata = context.state.oauthMetadata!;
|
||||||
|
const clientMetadata = context.provider.clientMetadata;
|
||||||
|
|
||||||
|
// Add all supported scopes to client registration
|
||||||
|
if (metadata.scopes_supported) {
|
||||||
|
clientMetadata.scope = metadata.scopes_supported.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullInformation = await registerClient(context.serverUrl, {
|
||||||
|
metadata,
|
||||||
|
clientMetadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
context.provider.saveClientInformation(fullInformation);
|
||||||
|
context.updateState({
|
||||||
|
oauthClientInfo: fullInformation,
|
||||||
|
oauthStep: "authorization_redirect",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
nextStep: "authorization_redirect",
|
||||||
|
},
|
||||||
|
|
||||||
|
authorization_redirect: {
|
||||||
|
canTransition: async (context) =>
|
||||||
|
!!context.state.oauthMetadata && !!context.state.oauthClientInfo,
|
||||||
|
execute: async (context) => {
|
||||||
|
const metadata = context.state.oauthMetadata!;
|
||||||
|
const clientInformation = context.state.oauthClientInfo!;
|
||||||
|
|
||||||
|
let scope: string | undefined = undefined;
|
||||||
|
if (metadata.scopes_supported) {
|
||||||
|
scope = metadata.scopes_supported.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { authorizationUrl, codeVerifier } = await startAuthorization(
|
||||||
|
context.serverUrl,
|
||||||
|
{
|
||||||
|
metadata,
|
||||||
|
clientInformation,
|
||||||
|
redirectUrl: context.provider.redirectUrl,
|
||||||
|
scope,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
context.provider.saveCodeVerifier(codeVerifier);
|
||||||
|
context.updateState({
|
||||||
|
authorizationUrl: authorizationUrl.toString(),
|
||||||
|
oauthStep: "authorization_code",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
nextStep: "authorization_code",
|
||||||
|
},
|
||||||
|
|
||||||
|
authorization_code: {
|
||||||
|
canTransition: async () => true,
|
||||||
|
execute: async (context) => {
|
||||||
|
if (
|
||||||
|
!context.state.authorizationCode ||
|
||||||
|
context.state.authorizationCode.trim() === ""
|
||||||
|
) {
|
||||||
|
context.updateState({
|
||||||
|
validationError: "You need to provide an authorization code",
|
||||||
|
});
|
||||||
|
// Don't advance if no code
|
||||||
|
throw new Error("Authorization code required");
|
||||||
|
}
|
||||||
|
context.updateState({
|
||||||
|
validationError: null,
|
||||||
|
oauthStep: "token_request",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
nextStep: "token_request",
|
||||||
|
},
|
||||||
|
|
||||||
|
token_request: {
|
||||||
|
canTransition: async (context) => {
|
||||||
|
return (
|
||||||
|
!!context.state.authorizationCode &&
|
||||||
|
!!context.provider.getServerMetadata() &&
|
||||||
|
!!(await context.provider.clientInformation())
|
||||||
|
);
|
||||||
|
},
|
||||||
|
execute: async (context) => {
|
||||||
|
const codeVerifier = context.provider.codeVerifier();
|
||||||
|
const metadata = context.provider.getServerMetadata()!;
|
||||||
|
const clientInformation = (await context.provider.clientInformation())!;
|
||||||
|
|
||||||
|
const tokens = await exchangeAuthorization(context.serverUrl, {
|
||||||
|
metadata,
|
||||||
|
clientInformation,
|
||||||
|
authorizationCode: context.state.authorizationCode,
|
||||||
|
codeVerifier,
|
||||||
|
redirectUri: context.provider.redirectUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
context.provider.saveTokens(tokens);
|
||||||
|
context.updateState({
|
||||||
|
oauthTokens: tokens,
|
||||||
|
oauthStep: "complete",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
nextStep: "complete",
|
||||||
|
},
|
||||||
|
|
||||||
|
complete: {
|
||||||
|
canTransition: async () => false,
|
||||||
|
execute: async () => {
|
||||||
|
// No-op for complete state
|
||||||
|
},
|
||||||
|
nextStep: "complete",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OAuthStateMachine {
|
||||||
|
constructor(
|
||||||
|
private serverUrl: string,
|
||||||
|
private updateState: (updates: Partial<AuthDebuggerState>) => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async executeStep(state: AuthDebuggerState): Promise<void> {
|
||||||
|
const provider = new DebugInspectorOAuthClientProvider(this.serverUrl);
|
||||||
|
const context: StateMachineContext = {
|
||||||
|
state,
|
||||||
|
serverUrl: this.serverUrl,
|
||||||
|
provider,
|
||||||
|
updateState: this.updateState,
|
||||||
|
};
|
||||||
|
|
||||||
|
const transition = oauthTransitions[state.oauthStep];
|
||||||
|
if (!(await transition.canTransition(context))) {
|
||||||
|
throw new Error(`Cannot transition from ${state.oauthStep}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transition.execute(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,146 @@
|
|||||||
import { updateValueAtPath, getValueAtPath } from "../jsonPathUtils";
|
import {
|
||||||
import { JsonValue } from "../../components/DynamicJsonForm";
|
getDataType,
|
||||||
|
tryParseJson,
|
||||||
|
updateValueAtPath,
|
||||||
|
getValueAtPath,
|
||||||
|
} from "../jsonUtils";
|
||||||
|
import type { JsonValue } from "../jsonUtils";
|
||||||
|
|
||||||
|
describe("getDataType", () => {
|
||||||
|
test("should return 'string' for string values", () => {
|
||||||
|
expect(getDataType("hello")).toBe("string");
|
||||||
|
expect(getDataType("")).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'number' for number values", () => {
|
||||||
|
expect(getDataType(123)).toBe("number");
|
||||||
|
expect(getDataType(0)).toBe("number");
|
||||||
|
expect(getDataType(-10)).toBe("number");
|
||||||
|
expect(getDataType(1.5)).toBe("number");
|
||||||
|
expect(getDataType(NaN)).toBe("number");
|
||||||
|
expect(getDataType(Infinity)).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'boolean' for boolean values", () => {
|
||||||
|
expect(getDataType(true)).toBe("boolean");
|
||||||
|
expect(getDataType(false)).toBe("boolean");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'undefined' for undefined value", () => {
|
||||||
|
expect(getDataType(undefined)).toBe("undefined");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'object' for object values", () => {
|
||||||
|
expect(getDataType({})).toBe("object");
|
||||||
|
expect(getDataType({ key: "value" })).toBe("object");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'array' for array values", () => {
|
||||||
|
expect(getDataType([])).toBe("array");
|
||||||
|
expect(getDataType([1, 2, 3])).toBe("array");
|
||||||
|
expect(getDataType(["a", "b", "c"])).toBe("array");
|
||||||
|
expect(getDataType([{}, { nested: true }])).toBe("array");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'null' for null value", () => {
|
||||||
|
expect(getDataType(null)).toBe("null");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tryParseJson", () => {
|
||||||
|
test("should correctly parse valid JSON object", () => {
|
||||||
|
const jsonString = '{"name":"test","value":123}';
|
||||||
|
const result = tryParseJson(jsonString);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual({ name: "test", value: 123 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should correctly parse valid JSON array", () => {
|
||||||
|
const jsonString = '[1,2,3,"test"]';
|
||||||
|
const result = tryParseJson(jsonString);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual([1, 2, 3, "test"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should correctly parse JSON with whitespace", () => {
|
||||||
|
const jsonString = ' { "name" : "test" } ';
|
||||||
|
const result = tryParseJson(jsonString);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual({ name: "test" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should correctly parse nested JSON structures", () => {
|
||||||
|
const jsonString =
|
||||||
|
'{"user":{"name":"test","details":{"age":30}},"items":[1,2,3]}';
|
||||||
|
const result = tryParseJson(jsonString);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual({
|
||||||
|
user: {
|
||||||
|
name: "test",
|
||||||
|
details: {
|
||||||
|
age: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: [1, 2, 3],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should correctly parse empty objects and arrays", () => {
|
||||||
|
expect(tryParseJson("{}").success).toBe(true);
|
||||||
|
expect(tryParseJson("{}").data).toEqual({});
|
||||||
|
|
||||||
|
expect(tryParseJson("[]").success).toBe(true);
|
||||||
|
expect(tryParseJson("[]").data).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return failure for non-JSON strings", () => {
|
||||||
|
const nonJsonString = "this is not json";
|
||||||
|
const result = tryParseJson(nonJsonString);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.data).toBe(nonJsonString);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return failure for malformed JSON", () => {
|
||||||
|
const malformedJson = '{"name":"test",}';
|
||||||
|
const result = tryParseJson(malformedJson);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.data).toBe(malformedJson);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return failure for strings with correct delimiters but invalid JSON", () => {
|
||||||
|
const invalidJson = "{name:test}";
|
||||||
|
const result = tryParseJson(invalidJson);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.data).toBe(invalidJson);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle edge cases", () => {
|
||||||
|
expect(tryParseJson("").success).toBe(false);
|
||||||
|
expect(tryParseJson("").data).toBe("");
|
||||||
|
|
||||||
|
expect(tryParseJson(" ").success).toBe(false);
|
||||||
|
expect(tryParseJson(" ").data).toBe(" ");
|
||||||
|
|
||||||
|
expect(tryParseJson("null").success).toBe(false);
|
||||||
|
expect(tryParseJson("null").data).toBe("null");
|
||||||
|
|
||||||
|
expect(tryParseJson('"string"').success).toBe(false);
|
||||||
|
expect(tryParseJson('"string"').data).toBe('"string"');
|
||||||
|
|
||||||
|
expect(tryParseJson("123").success).toBe(false);
|
||||||
|
expect(tryParseJson("123").data).toBe("123");
|
||||||
|
|
||||||
|
expect(tryParseJson("true").success).toBe(false);
|
||||||
|
expect(tryParseJson("true").data).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("updateValueAtPath", () => {
|
describe("updateValueAtPath", () => {
|
||||||
// Basic functionality tests
|
// Basic functionality tests
|
||||||
@@ -8,17 +149,17 @@ describe("updateValueAtPath", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("initializes an empty object when input is null/undefined and path starts with a string", () => {
|
test("initializes an empty object when input is null/undefined and path starts with a string", () => {
|
||||||
expect(updateValueAtPath(null as any, ["foo"], "bar")).toEqual({
|
expect(updateValueAtPath(null, ["foo"], "bar")).toEqual({
|
||||||
foo: "bar",
|
foo: "bar",
|
||||||
});
|
});
|
||||||
expect(updateValueAtPath(undefined as any, ["foo"], "bar")).toEqual({
|
expect(updateValueAtPath(undefined, ["foo"], "bar")).toEqual({
|
||||||
foo: "bar",
|
foo: "bar",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("initializes an empty array when input is null/undefined and path starts with a number", () => {
|
test("initializes an empty array when input is null/undefined and path starts with a number", () => {
|
||||||
expect(updateValueAtPath(null as any, ["0"], "bar")).toEqual(["bar"]);
|
expect(updateValueAtPath(null, ["0"], "bar")).toEqual(["bar"]);
|
||||||
expect(updateValueAtPath(undefined as any, ["0"], "bar")).toEqual(["bar"]);
|
expect(updateValueAtPath(undefined, ["0"], "bar")).toEqual(["bar"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Object update tests
|
// Object update tests
|
||||||
@@ -152,10 +293,8 @@ describe("getValueAtPath", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("returns default value when input is null/undefined", () => {
|
test("returns default value when input is null/undefined", () => {
|
||||||
expect(getValueAtPath(null as any, ["foo"], "default")).toBe("default");
|
expect(getValueAtPath(null, ["foo"], "default")).toBe("default");
|
||||||
expect(getValueAtPath(undefined as any, ["foo"], "default")).toBe(
|
expect(getValueAtPath(undefined, ["foo"], "default")).toBe("default");
|
||||||
"default",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles array indices correctly", () => {
|
test("handles array indices correctly", () => {
|
||||||
78
client/src/utils/__tests__/oauthUtils.ts
Normal file
78
client/src/utils/__tests__/oauthUtils.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
generateOAuthErrorDescription,
|
||||||
|
parseOAuthCallbackParams,
|
||||||
|
} from "@/utils/oauthUtils.ts";
|
||||||
|
|
||||||
|
describe("parseOAuthCallbackParams", () => {
|
||||||
|
it("Returns successful: true and code when present", () => {
|
||||||
|
expect(parseOAuthCallbackParams("?code=fake-code")).toEqual({
|
||||||
|
successful: true,
|
||||||
|
code: "fake-code",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("Returns successful: false and error when error is present", () => {
|
||||||
|
expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({
|
||||||
|
successful: false,
|
||||||
|
error: "access_denied",
|
||||||
|
error_description: null,
|
||||||
|
error_uri: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("Returns optional error metadata fields when present", () => {
|
||||||
|
const search =
|
||||||
|
"?error=access_denied&" +
|
||||||
|
"error_description=User%20Denied%20Request&" +
|
||||||
|
"error_uri=https%3A%2F%2Fexample.com%2Ferror-docs";
|
||||||
|
expect(parseOAuthCallbackParams(search)).toEqual({
|
||||||
|
successful: false,
|
||||||
|
error: "access_denied",
|
||||||
|
error_description: "User Denied Request",
|
||||||
|
error_uri: "https://example.com/error-docs",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("Returns error when nothing present", () => {
|
||||||
|
expect(parseOAuthCallbackParams("?")).toEqual({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "Missing code or error in response",
|
||||||
|
error_uri: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateOAuthErrorDescription", () => {
|
||||||
|
it("When only error is present", () => {
|
||||||
|
expect(
|
||||||
|
generateOAuthErrorDescription({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: null,
|
||||||
|
error_uri: null,
|
||||||
|
}),
|
||||||
|
).toBe("Error: invalid_request.");
|
||||||
|
});
|
||||||
|
it("When error description is present", () => {
|
||||||
|
expect(
|
||||||
|
generateOAuthErrorDescription({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "The request could not be completed as dialed",
|
||||||
|
error_uri: null,
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
"Error: invalid_request.\nDetails: The request could not be completed as dialed.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("When all fields present", () => {
|
||||||
|
expect(
|
||||||
|
generateOAuthErrorDescription({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "The request could not be completed as dialed",
|
||||||
|
error_uri: "https://example.com/error-docs",
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
"Error: invalid_request.\nDetails: The request could not be completed as dialed.\nMore info: https://example.com/error-docs.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
|
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
|
||||||
import { JsonSchemaType } from "../../components/DynamicJsonForm";
|
import type { JsonSchemaType } from "../jsonUtils";
|
||||||
|
|
||||||
describe("generateDefaultValue", () => {
|
describe("generateDefaultValue", () => {
|
||||||
test("generates default string", () => {
|
test("generates default string", () => {
|
||||||
@@ -51,13 +51,13 @@ describe("generateDefaultValue", () => {
|
|||||||
|
|
||||||
test("generates null for non-required primitive types", () => {
|
test("generates null for non-required primitive types", () => {
|
||||||
expect(generateDefaultValue({ type: "string", required: false })).toBe(
|
expect(generateDefaultValue({ type: "string", required: false })).toBe(
|
||||||
null,
|
undefined,
|
||||||
);
|
);
|
||||||
expect(generateDefaultValue({ type: "number", required: false })).toBe(
|
expect(generateDefaultValue({ type: "number", required: false })).toBe(
|
||||||
null,
|
undefined,
|
||||||
);
|
);
|
||||||
expect(generateDefaultValue({ type: "boolean", required: false })).toBe(
|
expect(generateDefaultValue({ type: "boolean", required: false })).toBe(
|
||||||
null,
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { InspectorConfig } from "@/lib/configurationTypes";
|
import { InspectorConfig } from "@/lib/configurationTypes";
|
||||||
import { DEFAULT_MCP_PROXY_LISTEN_PORT } from "@/lib/constants";
|
import {
|
||||||
|
DEFAULT_MCP_PROXY_LISTEN_PORT,
|
||||||
|
DEFAULT_INSPECTOR_CONFIG,
|
||||||
|
} from "@/lib/constants";
|
||||||
|
|
||||||
export const getMCPProxyAddress = (config: InspectorConfig): string => {
|
export const getMCPProxyAddress = (config: InspectorConfig): string => {
|
||||||
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string;
|
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string;
|
||||||
@@ -12,3 +15,112 @@ export const getMCPProxyAddress = (config: InspectorConfig): string => {
|
|||||||
export const getMCPServerRequestTimeout = (config: InspectorConfig): number => {
|
export const getMCPServerRequestTimeout = (config: InspectorConfig): number => {
|
||||||
return config.MCP_SERVER_REQUEST_TIMEOUT.value as number;
|
return config.MCP_SERVER_REQUEST_TIMEOUT.value as number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resetRequestTimeoutOnProgress = (
|
||||||
|
config: InspectorConfig,
|
||||||
|
): boolean => {
|
||||||
|
return config.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS.value as boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMCPServerRequestMaxTotalTimeout = (
|
||||||
|
config: InspectorConfig,
|
||||||
|
): number => {
|
||||||
|
return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSearchParam = (key: string): string | null => {
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
return url.searchParams.get(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInitialTransportType = ():
|
||||||
|
| "stdio"
|
||||||
|
| "sse"
|
||||||
|
| "streamable-http" => {
|
||||||
|
const param = getSearchParam("transport");
|
||||||
|
if (param === "stdio" || param === "sse" || param === "streamable-http") {
|
||||||
|
return param;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(localStorage.getItem("lastTransportType") as
|
||||||
|
| "stdio"
|
||||||
|
| "sse"
|
||||||
|
| "streamable-http") || "stdio"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInitialSseUrl = (): string => {
|
||||||
|
const param = getSearchParam("serverUrl");
|
||||||
|
if (param) return param;
|
||||||
|
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInitialCommand = (): string => {
|
||||||
|
const param = getSearchParam("serverCommand");
|
||||||
|
if (param) return param;
|
||||||
|
return localStorage.getItem("lastCommand") || "mcp-server-everything";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInitialArgs = (): string => {
|
||||||
|
const param = getSearchParam("serverArgs");
|
||||||
|
if (param) return param;
|
||||||
|
return localStorage.getItem("lastArgs") || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns a map of config key -> value from query params if present
|
||||||
|
export const getConfigOverridesFromQueryParams = (
|
||||||
|
defaultConfig: InspectorConfig,
|
||||||
|
): Partial<InspectorConfig> => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const overrides: Partial<InspectorConfig> = {};
|
||||||
|
for (const key of Object.keys(defaultConfig)) {
|
||||||
|
const param = url.searchParams.get(key);
|
||||||
|
if (param !== null) {
|
||||||
|
// Try to coerce to correct type based on default value
|
||||||
|
const defaultValue = defaultConfig[key as keyof InspectorConfig].value;
|
||||||
|
let value: string | number | boolean = param;
|
||||||
|
if (typeof defaultValue === "number") {
|
||||||
|
value = Number(param);
|
||||||
|
} else if (typeof defaultValue === "boolean") {
|
||||||
|
value = param === "true";
|
||||||
|
}
|
||||||
|
overrides[key as keyof InspectorConfig] = {
|
||||||
|
...defaultConfig[key as keyof InspectorConfig],
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return overrides;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initializeInspectorConfig = (
|
||||||
|
localStorageKey: string,
|
||||||
|
): InspectorConfig => {
|
||||||
|
const savedConfig = localStorage.getItem(localStorageKey);
|
||||||
|
let baseConfig: InspectorConfig;
|
||||||
|
if (savedConfig) {
|
||||||
|
// merge default config with saved config
|
||||||
|
const mergedConfig = {
|
||||||
|
...DEFAULT_INSPECTOR_CONFIG,
|
||||||
|
...JSON.parse(savedConfig),
|
||||||
|
} as InspectorConfig;
|
||||||
|
|
||||||
|
// update description of keys to match the new description (in case of any updates to the default config description)
|
||||||
|
for (const [key, value] of Object.entries(mergedConfig)) {
|
||||||
|
mergedConfig[key as keyof InspectorConfig] = {
|
||||||
|
...value,
|
||||||
|
label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
baseConfig = mergedConfig;
|
||||||
|
} else {
|
||||||
|
baseConfig = DEFAULT_INSPECTOR_CONFIG;
|
||||||
|
}
|
||||||
|
// Apply query param overrides
|
||||||
|
const overrides = getConfigOverridesFromQueryParams(DEFAULT_INSPECTOR_CONFIG);
|
||||||
|
return { ...baseConfig, ...overrides };
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,66 @@
|
|||||||
import { JsonValue } from "../components/DynamicJsonForm";
|
export type JsonValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
| JsonValue[]
|
||||||
|
| { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
export type JsonSchemaType = {
|
||||||
|
type:
|
||||||
|
| "string"
|
||||||
|
| "number"
|
||||||
|
| "integer"
|
||||||
|
| "boolean"
|
||||||
|
| "array"
|
||||||
|
| "object"
|
||||||
|
| "null";
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: JsonValue;
|
||||||
|
properties?: Record<string, JsonSchemaType>;
|
||||||
|
items?: JsonSchemaType;
|
||||||
|
};
|
||||||
|
|
||||||
export type JsonObject = { [key: string]: JsonValue };
|
export type JsonObject = { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
export type DataType =
|
||||||
|
| "string"
|
||||||
|
| "number"
|
||||||
|
| "bigint"
|
||||||
|
| "boolean"
|
||||||
|
| "symbol"
|
||||||
|
| "undefined"
|
||||||
|
| "object"
|
||||||
|
| "function"
|
||||||
|
| "array"
|
||||||
|
| "null";
|
||||||
|
|
||||||
|
export function getDataType(value: JsonValue): DataType {
|
||||||
|
if (Array.isArray(value)) return "array";
|
||||||
|
if (value === null) return "null";
|
||||||
|
return typeof value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryParseJson(str: string): {
|
||||||
|
success: boolean;
|
||||||
|
data: JsonValue;
|
||||||
|
} {
|
||||||
|
const trimmed = str.trim();
|
||||||
|
if (
|
||||||
|
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
|
||||||
|
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
||||||
|
) {
|
||||||
|
return { success: false, data: str };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { success: true, data: JSON.parse(str) };
|
||||||
|
} catch {
|
||||||
|
return { success: false, data: str };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates a value at a specific path in a nested JSON structure
|
* Updates a value at a specific path in a nested JSON structure
|
||||||
* @param obj The original JSON value
|
* @param obj The original JSON value
|
||||||
65
client/src/utils/oauthUtils.ts
Normal file
65
client/src/utils/oauthUtils.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// The parsed query parameters returned by the Authorization Server
|
||||||
|
// representing either a valid authorization_code or an error
|
||||||
|
// ref: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.2
|
||||||
|
type CallbackParams =
|
||||||
|
| {
|
||||||
|
successful: true;
|
||||||
|
// The authorization code is generated by the authorization server.
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
successful: false;
|
||||||
|
// The OAuth 2.1 Error Code.
|
||||||
|
// Usually one of:
|
||||||
|
// ```
|
||||||
|
// invalid_request, unauthorized_client, access_denied, unsupported_response_type,
|
||||||
|
// invalid_scope, server_error, temporarily_unavailable
|
||||||
|
// ```
|
||||||
|
error: string;
|
||||||
|
// Human-readable ASCII text providing additional information, used to assist the
|
||||||
|
// developer in understanding the error that occurred.
|
||||||
|
error_description: string | null;
|
||||||
|
// A URI identifying a human-readable web page with information about the error,
|
||||||
|
// used to provide the client developer with additional information about the error.
|
||||||
|
error_uri: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseOAuthCallbackParams = (location: string): CallbackParams => {
|
||||||
|
const params = new URLSearchParams(location);
|
||||||
|
|
||||||
|
const code = params.get("code");
|
||||||
|
if (code) {
|
||||||
|
return { successful: true, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = params.get("error");
|
||||||
|
const error_description = params.get("error_description");
|
||||||
|
const error_uri = params.get("error_uri");
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { successful: false, error, error_description, error_uri };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "Missing code or error in response",
|
||||||
|
error_uri: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateOAuthErrorDescription = (
|
||||||
|
params: Extract<CallbackParams, { successful: false }>,
|
||||||
|
): string => {
|
||||||
|
const error = params.error;
|
||||||
|
const errorDescription = params.error_description;
|
||||||
|
const errorUri = params.error_uri;
|
||||||
|
|
||||||
|
return [
|
||||||
|
`Error: ${error}.`,
|
||||||
|
errorDescription ? `Details: ${errorDescription}.` : "",
|
||||||
|
errorUri ? `More info: ${errorUri}.` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { JsonValue, JsonSchemaType } from "../components/DynamicJsonForm";
|
import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
|
||||||
import { JsonObject } from "./jsonPathUtils";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a default value based on a JSON schema type
|
* Generates a default value based on a JSON schema type
|
||||||
@@ -14,7 +13,7 @@ export function generateDefaultValue(schema: JsonSchemaType): JsonValue {
|
|||||||
if (!schema.required) {
|
if (!schema.required) {
|
||||||
if (schema.type === "array") return [];
|
if (schema.type === "array") return [];
|
||||||
if (schema.type === "object") return {};
|
if (schema.type === "object") return {};
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (schema.type) {
|
switch (schema.type) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"types": ["jest", "@testing-library/jest-dom", "node"]
|
"types": ["jest", "@testing-library/jest-dom", "node", "react", "react-dom"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 385 KiB After Width: | Height: | Size: 418 KiB |
6471
package-lock.json
generated
6471
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector",
|
"name": "@modelcontextprotocol/inspector",
|
||||||
"version": "0.8.1",
|
"version": "0.12.0",
|
||||||
"description": "Model Context Protocol inspector",
|
"description": "Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
@@ -8,45 +8,56 @@
|
|||||||
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
|
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"mcp-inspector": "./bin/cli.js"
|
"mcp-inspector": "cli/build/cli.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"bin",
|
|
||||||
"client/bin",
|
"client/bin",
|
||||||
"client/dist",
|
"client/dist",
|
||||||
"server/build"
|
"server/build",
|
||||||
|
"cli/build"
|
||||||
],
|
],
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"client",
|
"client",
|
||||||
"server"
|
"server",
|
||||||
|
"cli"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"",
|
"build": "npm run build-server && npm run build-client && npm run build-cli",
|
||||||
"dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows",
|
|
||||||
"test": "npm run prettier-check && cd client && npm test",
|
|
||||||
"build-server": "cd server && npm run build",
|
"build-server": "cd server && npm run build",
|
||||||
"build-client": "cd client && npm run build",
|
"build-client": "cd client && npm run build",
|
||||||
"build": "npm run build-server && npm run build-client",
|
"build-cli": "cd cli && npm run build",
|
||||||
|
"clean": "rimraf ./node_modules ./client/node_modules ./cli/node_modules ./build ./client/dist ./server/build ./cli/build ./package-lock.json && npm install",
|
||||||
|
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"",
|
||||||
|
"dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows\"",
|
||||||
|
"start": "node client/bin/start.js",
|
||||||
"start-server": "cd server && npm run start",
|
"start-server": "cd server && npm run start",
|
||||||
"start-client": "cd client && npm run preview",
|
"start-client": "cd client && npm run preview",
|
||||||
"start": "node ./bin/cli.js",
|
"test": "npm run prettier-check && cd client && npm test",
|
||||||
"prepare": "npm run build",
|
"test-cli": "cd cli && npm run test",
|
||||||
"prettier-fix": "prettier --write .",
|
"prettier-fix": "prettier --write .",
|
||||||
"prettier-check": "prettier --check .",
|
"prettier-check": "prettier --check .",
|
||||||
|
"prepare": "npm run build",
|
||||||
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
|
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/inspector-client": "^0.8.1",
|
"@modelcontextprotocol/inspector-cli": "^0.12.0",
|
||||||
"@modelcontextprotocol/inspector-server": "^0.8.1",
|
"@modelcontextprotocol/inspector-client": "^0.12.0",
|
||||||
|
"@modelcontextprotocol/inspector-server": "^0.12.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.11.2",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
"open": "^10.1.0",
|
||||||
"shell-quote": "^1.8.2",
|
"shell-quote": "^1.8.2",
|
||||||
"spawn-rx": "^5.1.2",
|
"spawn-rx": "^5.1.2",
|
||||||
"ts-node": "^10.9.2"
|
"ts-node": "^10.9.2",
|
||||||
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.7.5",
|
"@types/node": "^22.7.5",
|
||||||
"@types/shell-quote": "^1.7.5",
|
"@types/shell-quote": "^1.7.5",
|
||||||
"prettier": "3.3.3"
|
"jest-fixed-jsdom": "^0.0.9",
|
||||||
|
"prettier": "3.3.3",
|
||||||
|
"rimraf": "^6.0.1",
|
||||||
|
"typescript": "^5.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
sample-config.json
Normal file
19
sample-config.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"everything": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["@modelcontextprotocol/server-everything"],
|
||||||
|
"env": {
|
||||||
|
"HELLO": "Hello MCP!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"myserver": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["build/index.js", "arg1", "arg2"],
|
||||||
|
"env": {
|
||||||
|
"KEY": "value",
|
||||||
|
"KEY2": "value2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-server",
|
"name": "@modelcontextprotocol/inspector-server",
|
||||||
"version": "0.8.1",
|
"version": "0.12.0",
|
||||||
"description": "Server-side application for the Model Context Protocol inspector",
|
"description": "Server-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
@@ -27,9 +27,9 @@
|
|||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.8.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.0",
|
"express": "^5.1.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,28 @@ import {
|
|||||||
StdioClientTransport,
|
StdioClientTransport,
|
||||||
getDefaultEnvironment,
|
getDefaultEnvironment,
|
||||||
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||||
|
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { findActualExecutable } from "spawn-rx";
|
import { findActualExecutable } from "spawn-rx";
|
||||||
import mcpProxy from "./mcpProxy.js";
|
import mcpProxy from "./mcpProxy.js";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
const SSE_HEADERS_PASSTHROUGH = ["authorization"];
|
const SSE_HEADERS_PASSTHROUGH = [
|
||||||
|
"authorization",
|
||||||
|
"x-api-key",
|
||||||
|
"x-custom-header",
|
||||||
|
"x-auth-token",
|
||||||
|
"x-request-id",
|
||||||
|
"x-correlation-id"
|
||||||
|
];
|
||||||
|
const STREAMABLE_HTTP_HEADERS_PASSTHROUGH = [
|
||||||
|
"authorization",
|
||||||
|
"mcp-session-id",
|
||||||
|
"last-event-id",
|
||||||
|
];
|
||||||
|
|
||||||
const defaultEnvironment = {
|
const defaultEnvironment = {
|
||||||
...getDefaultEnvironment(),
|
...getDefaultEnvironment(),
|
||||||
@@ -35,8 +50,12 @@ const { values } = parseArgs({
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.header("Access-Control-Expose-Headers", "mcp-session-id");
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
let webAppTransports: SSEServerTransport[] = [];
|
const webAppTransports: Map<string, Transport> = new Map<string, Transport>(); // Transports by sessionId
|
||||||
|
|
||||||
const createTransport = async (req: express.Request): Promise<Transport> => {
|
const createTransport = async (req: express.Request): Promise<Transport> => {
|
||||||
const query = req.query;
|
const query = req.query;
|
||||||
@@ -94,6 +113,31 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
|
|||||||
|
|
||||||
console.log("Connected to SSE transport");
|
console.log("Connected to SSE transport");
|
||||||
return transport;
|
return transport;
|
||||||
|
} else if (transportType === "streamable-http") {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
Accept: "text/event-stream, application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const key of STREAMABLE_HTTP_HEADERS_PASSTHROUGH) {
|
||||||
|
if (req.headers[key] === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = req.headers[key];
|
||||||
|
headers[key] = Array.isArray(value) ? value[value.length - 1] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = new StreamableHTTPClientTransport(
|
||||||
|
new URL(query.url as string),
|
||||||
|
{
|
||||||
|
requestInit: {
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await transport.start();
|
||||||
|
console.log("Connected to Streamable HTTP transport");
|
||||||
|
return transport;
|
||||||
} else {
|
} else {
|
||||||
console.error(`Invalid transport type: ${transportType}`);
|
console.error(`Invalid transport type: ${transportType}`);
|
||||||
throw new Error("Invalid transport type specified");
|
throw new Error("Invalid transport type specified");
|
||||||
@@ -102,9 +146,96 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
|
|||||||
|
|
||||||
let backingServerTransport: Transport | undefined;
|
let backingServerTransport: Transport | undefined;
|
||||||
|
|
||||||
app.get("/sse", async (req, res) => {
|
app.get("/mcp", async (req, res) => {
|
||||||
|
const sessionId = req.headers["mcp-session-id"] as string;
|
||||||
|
console.log(`Received GET message for sessionId ${sessionId}`);
|
||||||
try {
|
try {
|
||||||
console.log("New SSE connection");
|
const transport = webAppTransports.get(
|
||||||
|
sessionId,
|
||||||
|
) as StreamableHTTPServerTransport;
|
||||||
|
if (!transport) {
|
||||||
|
res.status(404).end("Session not found");
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
await transport.handleRequest(req, res);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in /mcp route:", error);
|
||||||
|
res.status(500).json(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/mcp", async (req, res) => {
|
||||||
|
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||||
|
console.log(`Received POST message for sessionId ${sessionId}`);
|
||||||
|
if (!sessionId) {
|
||||||
|
try {
|
||||||
|
console.log("New streamable-http connection");
|
||||||
|
try {
|
||||||
|
await backingServerTransport?.close();
|
||||||
|
backingServerTransport = await createTransport(req);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SseError && error.code === 401) {
|
||||||
|
console.error(
|
||||||
|
"Received 401 Unauthorized from MCP server:",
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
|
res.status(401).json(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Connected MCP client to backing server transport");
|
||||||
|
|
||||||
|
const webAppTransport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: randomUUID,
|
||||||
|
onsessioninitialized: (sessionId) => {
|
||||||
|
webAppTransports.set(sessionId, webAppTransport);
|
||||||
|
console.log("Created streamable web app transport " + sessionId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await webAppTransport.start();
|
||||||
|
|
||||||
|
mcpProxy({
|
||||||
|
transportToClient: webAppTransport,
|
||||||
|
transportToServer: backingServerTransport,
|
||||||
|
});
|
||||||
|
|
||||||
|
await (webAppTransport as StreamableHTTPServerTransport).handleRequest(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
req.body,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in /mcp POST route:", error);
|
||||||
|
res.status(500).json(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const transport = webAppTransports.get(
|
||||||
|
sessionId,
|
||||||
|
) as StreamableHTTPServerTransport;
|
||||||
|
if (!transport) {
|
||||||
|
res.status(404).end("Transport not found for sessionId " + sessionId);
|
||||||
|
} else {
|
||||||
|
await (transport as StreamableHTTPServerTransport).handleRequest(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in /mcp route:", error);
|
||||||
|
res.status(500).json(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/stdio", async (req, res) => {
|
||||||
|
try {
|
||||||
|
console.log("New connection");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await backingServerTransport?.close();
|
await backingServerTransport?.close();
|
||||||
@@ -125,15 +256,14 @@ app.get("/sse", async (req, res) => {
|
|||||||
console.log("Connected MCP client to backing server transport");
|
console.log("Connected MCP client to backing server transport");
|
||||||
|
|
||||||
const webAppTransport = new SSEServerTransport("/message", res);
|
const webAppTransport = new SSEServerTransport("/message", res);
|
||||||
console.log("Created web app transport");
|
webAppTransports.set(webAppTransport.sessionId, webAppTransport);
|
||||||
|
|
||||||
webAppTransports.push(webAppTransport);
|
|
||||||
console.log("Created web app transport");
|
console.log("Created web app transport");
|
||||||
|
|
||||||
await webAppTransport.start();
|
await webAppTransport.start();
|
||||||
|
(backingServerTransport as StdioClientTransport).stderr!.on(
|
||||||
if (backingServerTransport instanceof StdioClientTransport) {
|
"data",
|
||||||
backingServerTransport.stderr!.on("data", (chunk) => {
|
(chunk) => {
|
||||||
webAppTransport.send({
|
webAppTransport.send({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
method: "notifications/stderr",
|
method: "notifications/stderr",
|
||||||
@@ -141,8 +271,50 @@ app.get("/sse", async (req, res) => {
|
|||||||
content: chunk.toString(),
|
content: chunk.toString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
mcpProxy({
|
||||||
|
transportToClient: webAppTransport,
|
||||||
|
transportToServer: backingServerTransport,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("Set up MCP proxy");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in /stdio route:", error);
|
||||||
|
res.status(500).json(error);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/sse", async (req, res) => {
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
"New SSE connection. NOTE: The sse transport is deprecated and has been replaced by streamable-http",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await backingServerTransport?.close();
|
||||||
|
backingServerTransport = await createTransport(req);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SseError && error.code === 401) {
|
||||||
|
console.error(
|
||||||
|
"Received 401 Unauthorized from MCP server:",
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
|
res.status(401).json(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Connected MCP client to backing server transport");
|
||||||
|
|
||||||
|
const webAppTransport = new SSEServerTransport("/message", res);
|
||||||
|
webAppTransports.set(webAppTransport.sessionId, webAppTransport);
|
||||||
|
console.log("Created web app transport");
|
||||||
|
|
||||||
|
await webAppTransport.start();
|
||||||
|
|
||||||
mcpProxy({
|
mcpProxy({
|
||||||
transportToClient: webAppTransport,
|
transportToClient: webAppTransport,
|
||||||
@@ -161,7 +333,9 @@ app.post("/message", async (req, res) => {
|
|||||||
const sessionId = req.query.sessionId;
|
const sessionId = req.query.sessionId;
|
||||||
console.log(`Received message for sessionId ${sessionId}`);
|
console.log(`Received message for sessionId ${sessionId}`);
|
||||||
|
|
||||||
const transport = webAppTransports.find((t) => t.sessionId === sessionId);
|
const transport = webAppTransports.get(
|
||||||
|
sessionId as string,
|
||||||
|
) as SSEServerTransport;
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
res.status(404).end("Session not found");
|
res.status(404).end("Session not found");
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user