@aigne/example-mcp-blocklet 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.local.example +4 -0
- package/README.md +52 -0
- package/index.ts +125 -0
- package/oauth.ts +217 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# MCP Blocklet Demo
|
|
2
|
+
|
|
3
|
+
This is a demonstration of using [AIGNE Framework](https://github.com/AIGNE-io/aigne-framework) and MCP to interact with apps hosted on the [Blocklet platform](https://github.com/blocklet).
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- [Node.js](https://nodejs.org) and npm installed on your machine
|
|
8
|
+
- [OpenAI API key](https://platform.openai.com/api-keys) used to interact with OpenAI API
|
|
9
|
+
- [Pnpm](https://pnpm.io) [Optional] if you want to run the example from source code
|
|
10
|
+
|
|
11
|
+
## Try without Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
export OPENAI_API_KEY=YOUR_OPENAI_API_KEY # setup your OpenAI API key
|
|
15
|
+
|
|
16
|
+
npx -y @aigne/example-mcp-blocklet # run the example
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
### Clone the Repository
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
git clone https://github.com/AIGNE-io/aigne-framework
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Install Dependencies
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cd aigne-framework/examples/mcp-blocklet
|
|
31
|
+
|
|
32
|
+
pnpm install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Setup Environment Variables
|
|
36
|
+
|
|
37
|
+
Setup your OpenAI API key in the `.env.local` file:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
OPENAI_API_KEY="" # setup your OpenAI API key here
|
|
41
|
+
BLOCKLET_APP_URL="" # setup your Blocklet app URL here
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Run the Example
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pnpm start
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
This project is licensed under the MIT License.
|
package/index.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env npx -y bun
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert";
|
|
4
|
+
import { runChatLoopInTerminal } from "@aigne/cli/utils/run-chat-loop.js";
|
|
5
|
+
import { AIAgent, ExecutionEngine, MCPAgent, PromptBuilder } from "@aigne/core";
|
|
6
|
+
import { OpenAIChatModel } from "@aigne/core/models/openai-chat-model.js";
|
|
7
|
+
import { logger } from "@aigne/core/utils/logger.js";
|
|
8
|
+
import { UnauthorizedError, refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
9
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
10
|
+
// @ts-ignore
|
|
11
|
+
import JWT from "jsonwebtoken";
|
|
12
|
+
|
|
13
|
+
import { TerminalOAuthProvider } from "./oauth.js";
|
|
14
|
+
|
|
15
|
+
logger.enable(`aigne:mcp,${process.env.DEBUG}`);
|
|
16
|
+
|
|
17
|
+
const { OPENAI_API_KEY, BLOCKLET_APP_URL } = process.env;
|
|
18
|
+
assert(OPENAI_API_KEY, "Please set the OPENAI_API_KEY environment variable");
|
|
19
|
+
assert(BLOCKLET_APP_URL, "Please set the BLOCKLET_APP_URL environment variable");
|
|
20
|
+
console.info("Connecting to blocklet app", BLOCKLET_APP_URL);
|
|
21
|
+
|
|
22
|
+
const appUrl = new URL(BLOCKLET_APP_URL);
|
|
23
|
+
appUrl.pathname = "/.well-known/service/mcp/sse";
|
|
24
|
+
|
|
25
|
+
const provider = new TerminalOAuthProvider(appUrl.host);
|
|
26
|
+
const authCodePromise = new Promise((resolve, reject) => {
|
|
27
|
+
provider.once("authorized", resolve);
|
|
28
|
+
provider.once("error", reject);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const transport = new SSEClientTransport(appUrl, {
|
|
32
|
+
authProvider: provider,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
let tokens = await provider.tokens();
|
|
37
|
+
if (tokens) {
|
|
38
|
+
let decoded = JWT.decode(tokens.access_token);
|
|
39
|
+
console.info("Decoded access token:", decoded);
|
|
40
|
+
if (decoded) {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const expiresAt = decoded.exp * 1000;
|
|
43
|
+
if (now < expiresAt) {
|
|
44
|
+
console.info("Tokens already exist and not expired, skipping authorization");
|
|
45
|
+
} else if (tokens.refresh_token) {
|
|
46
|
+
decoded = JWT.decode(tokens.refresh_token);
|
|
47
|
+
console.info("Decoded refresh token:", decoded);
|
|
48
|
+
if (decoded) {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const expiresAt = decoded.exp * 1000;
|
|
51
|
+
if (now < expiresAt) {
|
|
52
|
+
console.info("Refresh token already exists and not expired, refreshing authorization");
|
|
53
|
+
try {
|
|
54
|
+
tokens = await refreshAuthorization(appUrl.href, {
|
|
55
|
+
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
|
56
|
+
clientInformation: (await provider.clientInformation())!,
|
|
57
|
+
refreshToken: tokens.refresh_token,
|
|
58
|
+
});
|
|
59
|
+
await provider.saveTokens(tokens);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error(
|
|
62
|
+
"Error refreshing authorization, resetting tokens and starting authorization",
|
|
63
|
+
error,
|
|
64
|
+
);
|
|
65
|
+
await provider.saveTokens(undefined);
|
|
66
|
+
await transport.start();
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
console.info("Refresh token already expired, starting authorization");
|
|
70
|
+
await transport.start();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
console.info("No tokens found, starting authorization");
|
|
77
|
+
await transport.start();
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error instanceof UnauthorizedError) {
|
|
81
|
+
const code = await authCodePromise;
|
|
82
|
+
console.info("Authorization code received, finishing authorization...", Date.now());
|
|
83
|
+
await transport.finishAuth(code as string);
|
|
84
|
+
await transport.close();
|
|
85
|
+
} else {
|
|
86
|
+
console.error("Error authorizing:", error);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.info("Starting connecting to blocklet mcp...");
|
|
92
|
+
|
|
93
|
+
const model = new OpenAIChatModel({
|
|
94
|
+
apiKey: OPENAI_API_KEY,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const blocklet = await MCPAgent.from({
|
|
98
|
+
url: appUrl.href,
|
|
99
|
+
timeout: 8000,
|
|
100
|
+
opts: {
|
|
101
|
+
authProvider: provider,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const engine = new ExecutionEngine({
|
|
106
|
+
model,
|
|
107
|
+
tools: [blocklet],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const agent = AIAgent.from({
|
|
111
|
+
instructions: PromptBuilder.from(
|
|
112
|
+
"You are a helpful assistant that can help users query and analyze data from the blocklet. You can perform various database queries on the blocklet database, before performing any queries, please try to understand the user's request and generate a query base on the database schema.",
|
|
113
|
+
),
|
|
114
|
+
memory: true,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const userAgent = engine.call(agent);
|
|
118
|
+
|
|
119
|
+
await runChatLoopInTerminal(userAgent, {
|
|
120
|
+
welcome:
|
|
121
|
+
"Hello! I'm a chatbot that can help you interact with the blocklet. Try asking me a question about the blocklet!",
|
|
122
|
+
defaultQuestion: "How many users are there in the database?",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
process.exit(0);
|
package/oauth.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
6
|
+
import type {
|
|
7
|
+
OAuthClientInformation,
|
|
8
|
+
OAuthClientInformationFull,
|
|
9
|
+
OAuthTokens,
|
|
10
|
+
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
11
|
+
import open from "open";
|
|
12
|
+
|
|
13
|
+
export class TerminalOAuthProvider extends EventEmitter implements OAuthClientProvider {
|
|
14
|
+
private _tokens: OAuthTokens | undefined;
|
|
15
|
+
private _clientInformation: OAuthClientInformationFull | undefined;
|
|
16
|
+
|
|
17
|
+
private codeVerifierValue = "";
|
|
18
|
+
private localServerPort = 4444; // Choose an available port
|
|
19
|
+
private tokenFilePath: string;
|
|
20
|
+
private clientInfoPath: string;
|
|
21
|
+
|
|
22
|
+
constructor(host: string) {
|
|
23
|
+
super();
|
|
24
|
+
|
|
25
|
+
this.tokenFilePath = join(process.cwd(), ".oauth", host, "token.json");
|
|
26
|
+
this.clientInfoPath = join(process.cwd(), ".oauth", host, "client.json");
|
|
27
|
+
|
|
28
|
+
mkdirSync(join(process.cwd(), ".oauth", host), { recursive: true });
|
|
29
|
+
|
|
30
|
+
this.loadTokens();
|
|
31
|
+
this.loadClientInfo();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get redirectUrl() {
|
|
35
|
+
return `http://localhost:${this.localServerPort}/callback`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get clientMetadata() {
|
|
39
|
+
return {
|
|
40
|
+
redirect_uris: [this.redirectUrl],
|
|
41
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
42
|
+
response_types: ["code"],
|
|
43
|
+
client_name: "AIGNE Examples",
|
|
44
|
+
client_uri: "https://www.aigne.io/framework",
|
|
45
|
+
logo_uri: "https://www.aigne.io/.well-known/service/blocklet/logo",
|
|
46
|
+
scope: "profile:read blocklet:read blocklet:write",
|
|
47
|
+
tos_uri: "https://www.arcblock.io/en/termsofuse",
|
|
48
|
+
policy_uri: "https://www.arcblock.io/en/privacy",
|
|
49
|
+
contacts: ["support@aigne.io"],
|
|
50
|
+
software_id: "AIGNE Framework",
|
|
51
|
+
software_version: "1.0.0",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
|
56
|
+
return this._clientInformation;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> {
|
|
60
|
+
console.log("Saving client information:", clientInformation);
|
|
61
|
+
this._clientInformation = clientInformation;
|
|
62
|
+
this.persistClientInfo();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async tokens(): Promise<OAuthTokens | undefined> {
|
|
66
|
+
return this._tokens;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async saveTokens(tokens: OAuthTokens | undefined): Promise<void> {
|
|
70
|
+
if (tokens) {
|
|
71
|
+
console.log("Saving tokens:", tokens);
|
|
72
|
+
this._tokens = tokens;
|
|
73
|
+
this.persistTokens();
|
|
74
|
+
} else {
|
|
75
|
+
console.error("Reset tokens");
|
|
76
|
+
this._tokens = undefined;
|
|
77
|
+
this.persistTokens();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
|
82
|
+
// Create a local server to handle the callback
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const server = createServer(async (req, res) => {
|
|
85
|
+
if (req.url?.startsWith("/callback")) {
|
|
86
|
+
const url = new URL(req.url, this.redirectUrl);
|
|
87
|
+
const code = url.searchParams.get("code");
|
|
88
|
+
const error = url.searchParams.get("error");
|
|
89
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
90
|
+
|
|
91
|
+
// Send a response to close the browser window
|
|
92
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
93
|
+
res.end(`
|
|
94
|
+
<html>
|
|
95
|
+
<head>
|
|
96
|
+
<title>Authorization</title>
|
|
97
|
+
<meta charset="UTF-8">
|
|
98
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
99
|
+
<style>
|
|
100
|
+
body {
|
|
101
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
102
|
+
display: flex;
|
|
103
|
+
justify-content: center;
|
|
104
|
+
align-items: center;
|
|
105
|
+
min-height: 100vh;
|
|
106
|
+
margin: 0;
|
|
107
|
+
background-color: #f5f5f5;
|
|
108
|
+
color: #333;
|
|
109
|
+
}
|
|
110
|
+
.container {
|
|
111
|
+
background: white;
|
|
112
|
+
padding: 2rem;
|
|
113
|
+
border-radius: 8px;
|
|
114
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
115
|
+
text-align: center;
|
|
116
|
+
max-width: 400px;
|
|
117
|
+
width: 90%;
|
|
118
|
+
}
|
|
119
|
+
h1 {
|
|
120
|
+
color: ${error ? "#dc3545" : "#28a745"};
|
|
121
|
+
margin-bottom: 1rem;
|
|
122
|
+
}
|
|
123
|
+
p {
|
|
124
|
+
margin: 0.5rem 0;
|
|
125
|
+
line-height: 1.5;
|
|
126
|
+
}
|
|
127
|
+
.status-icon {
|
|
128
|
+
font-size: 3rem;
|
|
129
|
+
margin-bottom: 1rem;
|
|
130
|
+
}
|
|
131
|
+
</style>
|
|
132
|
+
</head>
|
|
133
|
+
<body>
|
|
134
|
+
<div class="container">
|
|
135
|
+
<div class="status-icon">${error ? "❌" : "✅"}</div>
|
|
136
|
+
<h1>Authorization ${error ? "Failed" : "Successful"}!</h1>
|
|
137
|
+
${errorDescription ? `<p>${errorDescription}</p>` : ""}
|
|
138
|
+
<p>You can close this window and return to the application.</p>
|
|
139
|
+
</div>
|
|
140
|
+
<script>window.close()</script>
|
|
141
|
+
</body>
|
|
142
|
+
</html>
|
|
143
|
+
`);
|
|
144
|
+
|
|
145
|
+
// Close the server
|
|
146
|
+
server.close();
|
|
147
|
+
|
|
148
|
+
if (code) {
|
|
149
|
+
this.emit("authorized", code);
|
|
150
|
+
console.info("Authorization successful!", Date.now());
|
|
151
|
+
resolve();
|
|
152
|
+
} else {
|
|
153
|
+
this.emit("error", new Error("No authorization code received"));
|
|
154
|
+
reject(new Error("No authorization code received"));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Start the local server
|
|
160
|
+
server.listen(this.localServerPort, async () => {
|
|
161
|
+
console.log("Please authorize the application in your browser...");
|
|
162
|
+
// Open the authorization URL in the default browser
|
|
163
|
+
await open(authorizationUrl.toString());
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
|
169
|
+
this.codeVerifierValue = codeVerifier;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async codeVerifier(): Promise<string> {
|
|
173
|
+
return this.codeVerifierValue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private loadTokens(): void {
|
|
177
|
+
try {
|
|
178
|
+
if (existsSync(this.tokenFilePath)) {
|
|
179
|
+
const data = readFileSync(this.tokenFilePath, "utf8");
|
|
180
|
+
this._tokens = JSON.parse(data);
|
|
181
|
+
}
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.error("Error loading tokens:", error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private persistTokens(): void {
|
|
188
|
+
try {
|
|
189
|
+
if (this._tokens) {
|
|
190
|
+
writeFileSync(this.tokenFilePath, JSON.stringify(this._tokens, null, 2));
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.error("Error persisting tokens:", error);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private loadClientInfo(): void {
|
|
198
|
+
try {
|
|
199
|
+
if (existsSync(this.clientInfoPath)) {
|
|
200
|
+
const data = readFileSync(this.clientInfoPath, "utf8");
|
|
201
|
+
this._clientInformation = JSON.parse(data);
|
|
202
|
+
}
|
|
203
|
+
} catch (error) {
|
|
204
|
+
console.error("Error loading client information:", error);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private persistClientInfo(): void {
|
|
209
|
+
try {
|
|
210
|
+
if (this._clientInformation) {
|
|
211
|
+
writeFileSync(this.clientInfoPath, JSON.stringify(this._clientInformation, null, 2));
|
|
212
|
+
}
|
|
213
|
+
} catch (error) {
|
|
214
|
+
console.error("Error persisting client information:", error);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aigne/example-mcp-blocklet",
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "A demonstration of using AIGNE Framework and MCP Server hosted by the Blocklet platform",
|
|
5
|
+
"author": "Arcblock <blocklet@arcblock.io> https://github.com/blocklet",
|
|
6
|
+
"homepage": "https://github.com/AIGNE-io/aigne-framework/tree/main/examples/mcp-blocklet",
|
|
7
|
+
"license": "ISC",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/AIGNE-io/aigne-framework"
|
|
11
|
+
},
|
|
12
|
+
"bin": "index.ts",
|
|
13
|
+
"files": [
|
|
14
|
+
".env.local.example",
|
|
15
|
+
"*.ts",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.9.0",
|
|
20
|
+
"jsonwebtoken": "^9.0.2",
|
|
21
|
+
"open": "^10.1.0",
|
|
22
|
+
"zod": "^3.24.2",
|
|
23
|
+
"@aigne/cli": "^1.2.0",
|
|
24
|
+
"@aigne/core": "^1.7.0"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"start": "npx -y bun run index.ts",
|
|
28
|
+
"lint": "tsc --noEmit"
|
|
29
|
+
}
|
|
30
|
+
}
|