@meetopenbot/notion 0.0.1

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,10 @@
1
+ import type { ModelMessage } from 'ai';
2
+ /** Cap on model messages (user, assistant, tool); includes tool-call/result pairs. */
3
+ export declare const MAX_HISTORY_MESSAGES = 60;
4
+ /**
5
+ * Walks persisted `events` in order and builds model messages: user turns, tool call/result
6
+ * pairs from `client:ui:widget` (Notion Action / Notion Result), and assistant text from
7
+ * `agent:output`.
8
+ */
9
+ export declare function buildConversationMessages(events: unknown[], agentId: string, currentUserContent: string): ModelMessage[];
10
+ export declare function trimHistory(messages: ModelMessage[], max: number): ModelMessage[];
@@ -0,0 +1,164 @@
1
+ /** Cap on model messages (user, assistant, tool); includes tool-call/result pairs. */
2
+ export const MAX_HISTORY_MESSAGES = 60;
3
+ let toolSequence = 0;
4
+ function nextToolCallId() {
5
+ return `hist-tool-${toolSequence++}`;
6
+ }
7
+ function resetToolIds() {
8
+ toolSequence = 0;
9
+ }
10
+ function tryParseJson(raw) {
11
+ const s = raw.trim();
12
+ if (!s)
13
+ return '';
14
+ try {
15
+ return JSON.parse(s);
16
+ }
17
+ catch {
18
+ return s;
19
+ }
20
+ }
21
+ /** Parses `Input: ...` / `Output: ...` blocks from persisted UI widget bodies. */
22
+ function parseNotionToolBody(body) {
23
+ const t = body.trim();
24
+ const outMarker = '\nOutput:';
25
+ const outIdx = t.indexOf(outMarker);
26
+ if (outIdx === -1) {
27
+ const onlyOut = t.match(/^Output:\s*([\s\S]*)$/i);
28
+ if (onlyOut)
29
+ return { output: tryParseJson(onlyOut[1]) };
30
+ const onlyIn = t.match(/^Input:\s*([\s\S]*)$/i);
31
+ if (onlyIn)
32
+ return { input: tryParseJson(onlyIn[1]) };
33
+ return {};
34
+ }
35
+ const head = t.slice(0, outIdx);
36
+ const tail = t.slice(outIdx + outMarker.length);
37
+ const inMatch = head.match(/^Input:\s*([\s\S]*)$/i);
38
+ const input = inMatch ? tryParseJson(inMatch[1]) : undefined;
39
+ const output = tryParseJson(tail);
40
+ return { input, output };
41
+ }
42
+ function toolOutputFromUnknown(value) {
43
+ if (value === undefined || value === null) {
44
+ return { type: 'text', value: '' };
45
+ }
46
+ if (typeof value === 'string') {
47
+ return { type: 'text', value };
48
+ }
49
+ return { type: 'json', value: value };
50
+ }
51
+ const ACTION_TITLE = /^Notion Action:\s*(.+)$/i;
52
+ const RESULT_TITLE = /^Notion Result:\s*(.+)$/i;
53
+ function pushToolRound(messages, toolName, input, output) {
54
+ const toolCallId = nextToolCallId();
55
+ messages.push({
56
+ role: 'assistant',
57
+ content: [
58
+ {
59
+ type: 'tool-call',
60
+ toolCallId,
61
+ toolName,
62
+ input,
63
+ },
64
+ ],
65
+ });
66
+ messages.push({
67
+ role: 'tool',
68
+ content: [
69
+ {
70
+ type: 'tool-result',
71
+ toolCallId,
72
+ toolName,
73
+ output: toolOutputFromUnknown(output),
74
+ },
75
+ ],
76
+ });
77
+ }
78
+ /**
79
+ * Walks persisted `events` in order and builds model messages: user turns, tool call/result
80
+ * pairs from `client:ui:widget` (Notion Action / Notion Result), and assistant text from
81
+ * `agent:output`.
82
+ */
83
+ export function buildConversationMessages(events, agentId, currentUserContent) {
84
+ const list = [];
85
+ const raw = Array.isArray(events) ? events : [];
86
+ let pendingToolName;
87
+ let pendingInput;
88
+ resetToolIds();
89
+ for (const item of raw) {
90
+ const e = item;
91
+ if (e.type === 'agent:invoke') {
92
+ const data = e.data;
93
+ const role = data?.role ?? 'user';
94
+ const text = data?.content;
95
+ if (role === 'user' && typeof text === 'string' && text.length > 0) {
96
+ resetToolIds();
97
+ list.push({ role: 'user', content: text });
98
+ }
99
+ }
100
+ else if (e.type === 'client:ui:widget' && e.meta?.agentId === agentId) {
101
+ const d = e.data;
102
+ if (d.kind !== 'message' || typeof d.body !== 'string' || !d.title)
103
+ continue;
104
+ const actionMatch = d.title.match(ACTION_TITLE);
105
+ if (actionMatch) {
106
+ const { input } = parseNotionToolBody(d.body);
107
+ pendingToolName = actionMatch[1].trim();
108
+ pendingInput = input;
109
+ continue;
110
+ }
111
+ const resultMatch = d.title.match(RESULT_TITLE);
112
+ if (!resultMatch)
113
+ continue;
114
+ const toolName = resultMatch[1].trim();
115
+ const { input: bodyInput, output } = parseNotionToolBody(d.body);
116
+ const input = bodyInput !== undefined
117
+ ? bodyInput
118
+ : pendingToolName === toolName
119
+ ? pendingInput
120
+ : undefined;
121
+ if (output !== undefined) {
122
+ pushToolRound(list, toolName, input ?? {}, output);
123
+ }
124
+ pendingToolName = undefined;
125
+ pendingInput = undefined;
126
+ }
127
+ else if (e.type === 'agent:output' && e.meta?.agentId === agentId) {
128
+ const text = e.data?.content;
129
+ if (typeof text === 'string' && text.trim()) {
130
+ list.push({ role: 'assistant', content: text.trim() });
131
+ }
132
+ }
133
+ }
134
+ return finalizeCurrentUserTurn(list, currentUserContent);
135
+ }
136
+ /**
137
+ * Ensures the pending user message is included exactly once (matches storage timing).
138
+ */
139
+ function userTextContent(msg) {
140
+ if (msg.role !== 'user')
141
+ return undefined;
142
+ const c = msg.content;
143
+ return typeof c === 'string' ? c : undefined;
144
+ }
145
+ function finalizeCurrentUserTurn(messages, currentUserContent) {
146
+ const last = messages[messages.length - 1];
147
+ if (!last) {
148
+ return [{ role: 'user', content: currentUserContent }];
149
+ }
150
+ if (last.role === 'assistant' || last.role === 'tool') {
151
+ return [...messages, { role: 'user', content: currentUserContent }];
152
+ }
153
+ if (last.role === 'user') {
154
+ if (userTextContent(last) === currentUserContent)
155
+ return messages;
156
+ return [...messages, { role: 'user', content: currentUserContent }];
157
+ }
158
+ return messages;
159
+ }
160
+ export function trimHistory(messages, max) {
161
+ if (messages.length <= max)
162
+ return messages;
163
+ return messages.slice(messages.length - max);
164
+ }
@@ -0,0 +1,22 @@
1
+ declare const _default: {
2
+ name: string;
3
+ description: string;
4
+ configSchema: {
5
+ type: "object";
6
+ properties: {
7
+ notionApiKey: {
8
+ type: "string";
9
+ description: string;
10
+ format: "password";
11
+ };
12
+ openaiApiKey: {
13
+ type: "string";
14
+ description: string;
15
+ format: "password";
16
+ };
17
+ };
18
+ required: never[];
19
+ };
20
+ factory: (context: import("@meetopenbot/plugin-sdk").PluginContext) => (builder: import("melony").MelonyBuilder<import("@meetopenbot/plugin-sdk").OpenBotState, import("@meetopenbot/plugin-sdk").OpenBotEvent>) => void;
21
+ };
22
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,162 @@
1
+ import { definePlugin, shouldHandleInvoke, agentOutput, uiWidget, } from '@meetopenbot/plugin-sdk';
2
+ import { generateText } from 'ai';
3
+ import { createOpenAI } from '@ai-sdk/openai';
4
+ import { createMCPClient } from '@ai-sdk/mcp';
5
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
6
+ import { buildConversationMessages, trimHistory, MAX_HISTORY_MESSAGES, } from './conversation-history.js';
7
+ export default definePlugin({
8
+ name: 'Notion Agent',
9
+ description: 'An agent that can interact with your Notion workspace using the official MCP server.',
10
+ configSchema: {
11
+ type: 'object',
12
+ properties: {
13
+ notionApiKey: {
14
+ type: 'string',
15
+ description: 'Your Notion Internal Integration Token',
16
+ format: 'password'
17
+ },
18
+ openaiApiKey: {
19
+ type: 'string',
20
+ description: 'Your OpenAI API Key',
21
+ format: 'password'
22
+ },
23
+ },
24
+ required: [],
25
+ },
26
+ factory: (context) => (builder) => {
27
+ const openai = createOpenAI({
28
+ apiKey: context.config.openaiApiKey || process.env.OPENAI_API_KEY,
29
+ });
30
+ builder.on('client:ui:widget:response', async function* (event) {
31
+ if (event.data.widgetId === 'notion-auth-form') {
32
+ const apiKey = event.data.values?.notionApiKey;
33
+ if (apiKey) {
34
+ await context.storage.createVariable({
35
+ key: 'NOTION_TOKEN',
36
+ value: apiKey,
37
+ secret: true
38
+ });
39
+ yield agentOutput({
40
+ agentId: context.agentId,
41
+ content: '✅ Notion API key saved! You can now use the Notion agent. Please try your request again.',
42
+ threadId: event.meta?.threadId
43
+ });
44
+ }
45
+ }
46
+ });
47
+ builder.on('agent:invoke', async function* (event, ctx) {
48
+ if (!shouldHandleInvoke(event, context.agentId))
49
+ return;
50
+ const threadId = event.meta?.threadId ?? ctx.state.threadId;
51
+ const userMessage = event.data.content;
52
+ const channelId = ctx.state.channelId;
53
+ // Check for Notion token in config or storage
54
+ const variables = await context.storage.getVariables();
55
+ const storedToken = variables['NOTION_TOKEN'];
56
+ const notionToken = context.config.notionApiKey ||
57
+ (typeof storedToken === 'string' ? storedToken : storedToken?.value);
58
+ if (!notionToken) {
59
+ yield agentOutput({
60
+ agentId: context.agentId,
61
+ content: 'I need your Notion API key to proceed. Please provide it below:',
62
+ threadId
63
+ });
64
+ yield uiWidget({
65
+ agentId: context.agentId,
66
+ widget: {
67
+ kind: 'form',
68
+ widgetId: 'notion-auth-form',
69
+ title: 'Notion Authentication',
70
+ fields: [
71
+ {
72
+ id: 'notionApiKey',
73
+ label: 'Notion API Key',
74
+ type: 'text',
75
+ placeholder: 'secret_...',
76
+ required: true
77
+ }
78
+ ],
79
+ submitLabel: 'Save Key'
80
+ },
81
+ threadId
82
+ });
83
+ return;
84
+ }
85
+ let conversationMessages = [{ role: 'user', content: userMessage }];
86
+ if (channelId) {
87
+ try {
88
+ const rawEvents = await context.storage.getEvents({
89
+ channelId,
90
+ ...(threadId ? { threadId } : {}),
91
+ });
92
+ conversationMessages = trimHistory(buildConversationMessages(rawEvents, context.agentId, userMessage), MAX_HISTORY_MESSAGES);
93
+ }
94
+ catch {
95
+ // Fall back to single-turn if history cannot be loaded
96
+ }
97
+ }
98
+ // Initialize the Notion MCP client using stdio transport
99
+ const mcpClient = await createMCPClient({
100
+ transport: new StdioClientTransport({
101
+ command: 'npx',
102
+ args: ['-y', '@notionhq/notion-mcp-server'],
103
+ env: {
104
+ NOTION_TOKEN: notionToken,
105
+ // Pass through path and other essential env vars
106
+ PATH: process.env.PATH || '',
107
+ HOME: process.env.HOME || '',
108
+ },
109
+ }),
110
+ });
111
+ try {
112
+ // Discover all tools from the Notion MCP server
113
+ const tools = await mcpClient.tools();
114
+ const result = await generateText({
115
+ model: openai('gpt-4o'),
116
+ system: `You are a helpful Notion assistant. You have access to the official Notion MCP tools.
117
+ Use these tools to search, read, create, and update content in the user's Notion workspace.
118
+ Always explain what you are doing. If you need a page or database ID, use the search tools first.
119
+ You may see prior user and assistant turns in the conversation; use them for continuity and follow-ups.`,
120
+ messages: conversationMessages,
121
+ // @ts-ignore
122
+ maxSteps: 10,
123
+ tools,
124
+ });
125
+ // Log tool usage to the UI using widgets
126
+ for (const step of result.steps) {
127
+ if (step.toolResults) {
128
+ for (const toolResult of step.toolResults) {
129
+ yield uiWidget({
130
+ agentId: context.agentId,
131
+ widget: {
132
+ kind: 'message',
133
+ title: `Notion Result: ${toolResult.toolName}`,
134
+ // @ts-ignore
135
+ body: `Input: ${JSON.stringify(toolResult.input, null, 2)}\nOutput: ${JSON.stringify(toolResult.result || toolResult.output, null, 2)}`,
136
+ display: 'collapsed'
137
+ },
138
+ threadId
139
+ });
140
+ }
141
+ }
142
+ }
143
+ yield agentOutput({
144
+ agentId: context.agentId,
145
+ content: result.text,
146
+ threadId,
147
+ });
148
+ }
149
+ catch (error) {
150
+ yield agentOutput({
151
+ agentId: context.agentId,
152
+ content: `I encountered an error while communicating with Notion: ${error.message}`,
153
+ threadId,
154
+ });
155
+ }
156
+ finally {
157
+ // Ensure the MCP connection is closed to prevent resource leaks
158
+ await mcpClient.close();
159
+ }
160
+ });
161
+ },
162
+ });
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@meetopenbot/notion",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "Notion agent kind plugin for OpenBot",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "test": "echo \"Error: no test specified\" && exit 1"
14
+ },
15
+ "keywords": [
16
+ "openbot",
17
+ "plugin",
18
+ "notion"
19
+ ],
20
+ "author": "",
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "@meetopenbot/plugin-sdk": "latest",
24
+ "@notionhq/client": "latest",
25
+ "ai": "latest",
26
+ "@ai-sdk/openai": "latest",
27
+ "@ai-sdk/mcp": "latest",
28
+ "@modelcontextprotocol/sdk": "latest",
29
+ "zod": "latest"
30
+ },
31
+ "devDependencies": {
32
+ "typescript": "^5.0.0",
33
+ "@types/node": "^20.0.0"
34
+ }
35
+ }
@@ -0,0 +1,209 @@
1
+ import type { JSONValue } from '@ai-sdk/provider';
2
+ import type { ModelMessage } from 'ai';
3
+ import type { ToolResultOutput } from '@ai-sdk/provider-utils';
4
+
5
+ /** Cap on model messages (user, assistant, tool); includes tool-call/result pairs. */
6
+ export const MAX_HISTORY_MESSAGES = 60;
7
+
8
+ type StoredEvent = {
9
+ type: string;
10
+ data?: Record<string, unknown>;
11
+ meta?: {
12
+ agentId?: string;
13
+ threadId?: string;
14
+ };
15
+ };
16
+
17
+ type MessageWidgetData = {
18
+ kind?: string;
19
+ title?: string;
20
+ body?: string;
21
+ };
22
+
23
+ let toolSequence = 0;
24
+
25
+ function nextToolCallId(): string {
26
+ return `hist-tool-${toolSequence++}`;
27
+ }
28
+
29
+ function resetToolIds(): void {
30
+ toolSequence = 0;
31
+ }
32
+
33
+ function tryParseJson(raw: string): unknown {
34
+ const s = raw.trim();
35
+ if (!s) return '';
36
+ try {
37
+ return JSON.parse(s) as unknown;
38
+ } catch {
39
+ return s;
40
+ }
41
+ }
42
+
43
+ /** Parses `Input: ...` / `Output: ...` blocks from persisted UI widget bodies. */
44
+ function parseNotionToolBody(body: string): { input?: unknown; output?: unknown } {
45
+ const t = body.trim();
46
+ const outMarker = '\nOutput:';
47
+ const outIdx = t.indexOf(outMarker);
48
+
49
+ if (outIdx === -1) {
50
+ const onlyOut = t.match(/^Output:\s*([\s\S]*)$/i);
51
+ if (onlyOut) return { output: tryParseJson(onlyOut[1]) };
52
+ const onlyIn = t.match(/^Input:\s*([\s\S]*)$/i);
53
+ if (onlyIn) return { input: tryParseJson(onlyIn[1]) };
54
+ return {};
55
+ }
56
+
57
+ const head = t.slice(0, outIdx);
58
+ const tail = t.slice(outIdx + outMarker.length);
59
+ const inMatch = head.match(/^Input:\s*([\s\S]*)$/i);
60
+ const input = inMatch ? tryParseJson(inMatch[1]) : undefined;
61
+ const output = tryParseJson(tail);
62
+ return { input, output };
63
+ }
64
+
65
+ function toolOutputFromUnknown(value: unknown): ToolResultOutput {
66
+ if (value === undefined || value === null) {
67
+ return { type: 'text', value: '' };
68
+ }
69
+ if (typeof value === 'string') {
70
+ return { type: 'text', value };
71
+ }
72
+ return { type: 'json', value: value as JSONValue };
73
+ }
74
+
75
+ const ACTION_TITLE = /^Notion Action:\s*(.+)$/i;
76
+ const RESULT_TITLE = /^Notion Result:\s*(.+)$/i;
77
+
78
+ function pushToolRound(
79
+ messages: ModelMessage[],
80
+ toolName: string,
81
+ input: unknown,
82
+ output: unknown
83
+ ): void {
84
+ const toolCallId = nextToolCallId();
85
+ messages.push({
86
+ role: 'assistant',
87
+ content: [
88
+ {
89
+ type: 'tool-call',
90
+ toolCallId,
91
+ toolName,
92
+ input,
93
+ },
94
+ ],
95
+ });
96
+ messages.push({
97
+ role: 'tool',
98
+ content: [
99
+ {
100
+ type: 'tool-result',
101
+ toolCallId,
102
+ toolName,
103
+ output: toolOutputFromUnknown(output),
104
+ },
105
+ ],
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Walks persisted `events` in order and builds model messages: user turns, tool call/result
111
+ * pairs from `client:ui:widget` (Notion Action / Notion Result), and assistant text from
112
+ * `agent:output`.
113
+ */
114
+ export function buildConversationMessages(
115
+ events: unknown[],
116
+ agentId: string,
117
+ currentUserContent: string
118
+ ): ModelMessage[] {
119
+ const list: ModelMessage[] = [];
120
+ const raw = Array.isArray(events) ? events : [];
121
+
122
+ let pendingToolName: string | undefined;
123
+ let pendingInput: unknown;
124
+
125
+ resetToolIds();
126
+
127
+ for (const item of raw) {
128
+ const e = item as StoredEvent;
129
+
130
+ if (e.type === 'agent:invoke') {
131
+ const data = e.data as { content?: string; role?: string } | undefined;
132
+ const role = data?.role ?? 'user';
133
+ const text = data?.content;
134
+ if (role === 'user' && typeof text === 'string' && text.length > 0) {
135
+ resetToolIds();
136
+ list.push({ role: 'user', content: text });
137
+ }
138
+ } else if (e.type === 'client:ui:widget' && e.meta?.agentId === agentId) {
139
+ const d = e.data as MessageWidgetData;
140
+ if (d.kind !== 'message' || typeof d.body !== 'string' || !d.title) continue;
141
+
142
+ const actionMatch = d.title.match(ACTION_TITLE);
143
+ if (actionMatch) {
144
+ const { input } = parseNotionToolBody(d.body);
145
+ pendingToolName = actionMatch[1].trim();
146
+ pendingInput = input;
147
+ continue;
148
+ }
149
+
150
+ const resultMatch = d.title.match(RESULT_TITLE);
151
+ if (!resultMatch) continue;
152
+
153
+ const toolName = resultMatch[1].trim();
154
+ const { input: bodyInput, output } = parseNotionToolBody(d.body);
155
+ const input =
156
+ bodyInput !== undefined
157
+ ? bodyInput
158
+ : pendingToolName === toolName
159
+ ? pendingInput
160
+ : undefined;
161
+
162
+ if (output !== undefined) {
163
+ pushToolRound(list, toolName, input ?? {}, output);
164
+ }
165
+
166
+ pendingToolName = undefined;
167
+ pendingInput = undefined;
168
+ } else if (e.type === 'agent:output' && e.meta?.agentId === agentId) {
169
+ const text = (e.data as { content?: string })?.content;
170
+ if (typeof text === 'string' && text.trim()) {
171
+ list.push({ role: 'assistant', content: text.trim() });
172
+ }
173
+ }
174
+ }
175
+
176
+ return finalizeCurrentUserTurn(list, currentUserContent);
177
+ }
178
+
179
+ /**
180
+ * Ensures the pending user message is included exactly once (matches storage timing).
181
+ */
182
+ function userTextContent(msg: ModelMessage): string | undefined {
183
+ if (msg.role !== 'user') return undefined;
184
+ const c = msg.content;
185
+ return typeof c === 'string' ? c : undefined;
186
+ }
187
+
188
+ function finalizeCurrentUserTurn(
189
+ messages: ModelMessage[],
190
+ currentUserContent: string
191
+ ): ModelMessage[] {
192
+ const last = messages[messages.length - 1];
193
+ if (!last) {
194
+ return [{ role: 'user', content: currentUserContent }];
195
+ }
196
+ if (last.role === 'assistant' || last.role === 'tool') {
197
+ return [...messages, { role: 'user', content: currentUserContent }];
198
+ }
199
+ if (last.role === 'user') {
200
+ if (userTextContent(last) === currentUserContent) return messages;
201
+ return [...messages, { role: 'user', content: currentUserContent }];
202
+ }
203
+ return messages;
204
+ }
205
+
206
+ export function trimHistory(messages: ModelMessage[], max: number): ModelMessage[] {
207
+ if (messages.length <= max) return messages;
208
+ return messages.slice(messages.length - max);
209
+ }
package/src/index.ts ADDED
@@ -0,0 +1,185 @@
1
+ import {
2
+ definePlugin,
3
+ shouldHandleInvoke,
4
+ agentOutput,
5
+ uiWidget,
6
+ type PluginHandlerContext,
7
+ } from '@meetopenbot/plugin-sdk';
8
+ import { generateText } from 'ai';
9
+ import type { ModelMessage } from 'ai';
10
+ import { createOpenAI } from '@ai-sdk/openai';
11
+ import { createMCPClient } from '@ai-sdk/mcp';
12
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
13
+ import {
14
+ buildConversationMessages,
15
+ trimHistory,
16
+ MAX_HISTORY_MESSAGES,
17
+ } from './conversation-history.js';
18
+
19
+ export default definePlugin({
20
+ name: 'Notion Agent',
21
+ description: 'An agent that can interact with your Notion workspace using the official MCP server.',
22
+ configSchema: {
23
+ type: 'object',
24
+ properties: {
25
+ notionApiKey: {
26
+ type: 'string',
27
+ description: 'Your Notion Internal Integration Token',
28
+ format: 'password'
29
+ },
30
+ openaiApiKey: {
31
+ type: 'string',
32
+ description: 'Your OpenAI API Key',
33
+ format: 'password'
34
+ },
35
+ },
36
+ required: [],
37
+ },
38
+ factory: (context) => (builder) => {
39
+ const openai = createOpenAI({
40
+ apiKey: context.config.openaiApiKey as string || process.env.OPENAI_API_KEY,
41
+ });
42
+
43
+ builder.on('client:ui:widget:response', async function* (event) {
44
+ if (event.data.widgetId === 'notion-auth-form') {
45
+ const apiKey = event.data.values?.notionApiKey as string;
46
+ if (apiKey) {
47
+ await context.storage.createVariable({
48
+ key: 'NOTION_TOKEN',
49
+ value: apiKey,
50
+ secret: true
51
+ });
52
+ yield agentOutput({
53
+ agentId: context.agentId,
54
+ content: '✅ Notion API key saved! You can now use the Notion agent. Please try your request again.',
55
+ threadId: event.meta?.threadId
56
+ });
57
+ }
58
+ }
59
+ });
60
+
61
+ builder.on('agent:invoke', async function* (event, ctx: PluginHandlerContext) {
62
+ if (!shouldHandleInvoke(event, context.agentId)) return;
63
+
64
+ const threadId = event.meta?.threadId ?? ctx.state.threadId;
65
+ const userMessage = event.data.content;
66
+ const channelId = ctx.state.channelId;
67
+
68
+ // Check for Notion token in config or storage
69
+ const variables = await context.storage.getVariables();
70
+ const storedToken = variables['NOTION_TOKEN'];
71
+ const notionToken = (context.config.notionApiKey as string) ||
72
+ (typeof storedToken === 'string' ? storedToken : storedToken?.value);
73
+
74
+ if (!notionToken) {
75
+ yield agentOutput({
76
+ agentId: context.agentId,
77
+ content: 'I need your Notion API key to proceed. Please provide it below:',
78
+ threadId
79
+ });
80
+ yield uiWidget({
81
+ agentId: context.agentId,
82
+ widget: {
83
+ kind: 'form',
84
+ widgetId: 'notion-auth-form',
85
+ title: 'Notion Authentication',
86
+ fields: [
87
+ {
88
+ id: 'notionApiKey',
89
+ label: 'Notion API Key',
90
+ type: 'text',
91
+ placeholder: 'secret_...',
92
+ required: true
93
+ }
94
+ ],
95
+ submitLabel: 'Save Key'
96
+ },
97
+ threadId
98
+ });
99
+ return;
100
+ }
101
+
102
+ let conversationMessages: ModelMessage[] = [{ role: 'user', content: userMessage }];
103
+ if (channelId) {
104
+ try {
105
+ const rawEvents = await context.storage.getEvents({
106
+ channelId,
107
+ ...(threadId ? { threadId } : {}),
108
+ });
109
+ conversationMessages = trimHistory(
110
+ buildConversationMessages(rawEvents, context.agentId, userMessage),
111
+ MAX_HISTORY_MESSAGES
112
+ );
113
+ } catch {
114
+ // Fall back to single-turn if history cannot be loaded
115
+ }
116
+ }
117
+
118
+ // Initialize the Notion MCP client using stdio transport
119
+ const mcpClient = await createMCPClient({
120
+ transport: new StdioClientTransport({
121
+ command: 'npx',
122
+ args: ['-y', '@notionhq/notion-mcp-server'],
123
+ env: {
124
+ NOTION_TOKEN: notionToken,
125
+ // Pass through path and other essential env vars
126
+ PATH: process.env.PATH || '',
127
+ HOME: process.env.HOME || '',
128
+ },
129
+ }),
130
+ });
131
+
132
+ try {
133
+ // Discover all tools from the Notion MCP server
134
+ const tools = await mcpClient.tools();
135
+
136
+ const result = await generateText({
137
+ model: openai('gpt-4o'),
138
+ system: `You are a helpful Notion assistant. You have access to the official Notion MCP tools.
139
+ Use these tools to search, read, create, and update content in the user's Notion workspace.
140
+ Always explain what you are doing. If you need a page or database ID, use the search tools first.
141
+ You may see prior user and assistant turns in the conversation; use them for continuity and follow-ups.`,
142
+ messages: conversationMessages,
143
+ // @ts-ignore
144
+ maxSteps: 10,
145
+ tools,
146
+ });
147
+
148
+ // Log tool usage to the UI using widgets
149
+ for (const step of result.steps) {
150
+ if (step.toolResults) {
151
+ for (const toolResult of step.toolResults) {
152
+ yield uiWidget({
153
+ agentId: context.agentId,
154
+ widget: {
155
+ kind: 'message',
156
+ title: `Notion Result: ${toolResult.toolName}`,
157
+ // @ts-ignore
158
+ body: `Input: ${JSON.stringify(toolResult.input, null, 2)}\nOutput: ${JSON.stringify(toolResult.result || (toolResult as any).output, null, 2)}`,
159
+ display: 'collapsed'
160
+ },
161
+ threadId
162
+ });
163
+ }
164
+ }
165
+ }
166
+
167
+ yield agentOutput({
168
+ agentId: context.agentId,
169
+ content: result.text,
170
+ threadId,
171
+ });
172
+
173
+ } catch (error: any) {
174
+ yield agentOutput({
175
+ agentId: context.agentId,
176
+ content: `I encountered an error while communicating with Notion: ${error.message}`,
177
+ threadId,
178
+ });
179
+ } finally {
180
+ // Ensure the MCP connection is closed to prevent resource leaks
181
+ await mcpClient.close();
182
+ }
183
+ });
184
+ },
185
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "declaration": true
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules", "dist"]
16
+ }