@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.
@@ -0,0 +1,4 @@
1
+ # Change the name of this file to .env.local and fill in the following values
2
+
3
+ OPENAI_API_KEY="" # Your OpenAI API key
4
+ BLOCKLET_APP_URL="" # Your Blocklet app URL
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
+ }