m365connector 0.1.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/README.md +101 -0
- package/bin/cli.js +2 -0
- package/package.json +28 -0
- package/src/index.js +303 -0
- package/src/tools/search.js +201 -0
- package/src/ws-server.js +202 -0
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# m365connector
|
|
2
|
+
|
|
3
|
+
MCP server that lets AI agents search and read your Microsoft 365 data (emails, files, Teams chats, calendar events, people, and external connectors).
|
|
4
|
+
|
|
5
|
+
Works with any MCP-compatible client: Claude Code, Claude Desktop, etc.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
MCP Client m365connector Browser Extension
|
|
11
|
+
(Claude Code) (this package) (Chrome/Edge MV3)
|
|
12
|
+
|
|
13
|
+
HTTP :52366 WebSocket :52365
|
|
14
|
+
───────────► ◄──────────────────►
|
|
15
|
+
Streamable HTTP IPC bridge Captures tokens,
|
|
16
|
+
transport (localhost) executes API calls
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The MCP server runs as a long-lived process. A companion browser extension connects over WebSocket and handles all M365 API calls using tokens captured from the browser.
|
|
20
|
+
|
|
21
|
+
## Prerequisites
|
|
22
|
+
|
|
23
|
+
- Node.js 18+
|
|
24
|
+
- The [M365 Connector browser extension](https://github.com/anthropics/m365-connector-extension) loaded in Chrome or Edge
|
|
25
|
+
- An M365 account (work or school)
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx m365connector
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
You should see:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
[workiq/ws] listening on ws://127.0.0.1:52365
|
|
37
|
+
[workiq] MCP HTTP listening on http://127.0.0.1:52366/mcp
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then add to your MCP client config (e.g. `~/.claude/mcp.json`):
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"mcpServers": {
|
|
45
|
+
"m365-connector": {
|
|
46
|
+
"type": "http",
|
|
47
|
+
"url": "http://127.0.0.1:52366/mcp"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Global install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm install -g m365connector
|
|
57
|
+
m365connector
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Environment variables
|
|
61
|
+
|
|
62
|
+
| Variable | Default | Description |
|
|
63
|
+
|----------|---------|-------------|
|
|
64
|
+
| `WORKIQ_MCP_PORT` | 52366 | HTTP port for MCP clients |
|
|
65
|
+
| `WORKIQ_WS_PORT` | 52365 | WebSocket port for extension IPC |
|
|
66
|
+
|
|
67
|
+
## Tools
|
|
68
|
+
|
|
69
|
+
### search
|
|
70
|
+
|
|
71
|
+
Search across M365 data sources.
|
|
72
|
+
|
|
73
|
+
| Parameter | Type | Required | Default | Description |
|
|
74
|
+
|-----------|------|----------|---------|-------------|
|
|
75
|
+
| query | string | yes | | Search query |
|
|
76
|
+
| conversation_id | string | yes | | Conversation identifier for grouping requests |
|
|
77
|
+
| source | string | no | `"all"` | `all`, `email`, `files`, `chat`, `events`, `people`, `external` |
|
|
78
|
+
| connector | string | no | | External connector name (when source=`external`) |
|
|
79
|
+
| size | integer | no | 10 | Results per page (1--25) |
|
|
80
|
+
| from | integer | no | 0 | Pagination offset |
|
|
81
|
+
|
|
82
|
+
### open
|
|
83
|
+
|
|
84
|
+
Read the content of a search result.
|
|
85
|
+
|
|
86
|
+
| Parameter | Type | Required | Description |
|
|
87
|
+
|-----------|------|----------|-------------|
|
|
88
|
+
| read_handle | string | yes | Opaque handle from a search result's `_readHandle` field |
|
|
89
|
+
| conversation_id | string | yes | Conversation identifier |
|
|
90
|
+
|
|
91
|
+
Returns content with a `completeness` field: `full`, `partial`, or `snippet`.
|
|
92
|
+
|
|
93
|
+
## Security
|
|
94
|
+
|
|
95
|
+
- Both servers bind to `127.0.0.1` only (not accessible from the network)
|
|
96
|
+
- WebSocket accepts connections only from `chrome-extension://` origins
|
|
97
|
+
- Read handles are server-issued opaque UUIDs with 30-minute TTL
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT
|
package/bin/cli.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "m365connector",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for M365 Connector browser extension",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"m365connector": "bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node src/index.js"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mcp",
|
|
19
|
+
"microsoft365",
|
|
20
|
+
"m365",
|
|
21
|
+
"search"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.27.0",
|
|
26
|
+
"ws": "^8.18.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
6
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
7
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import { WsServer } from "./ws-server.js";
|
|
10
|
+
|
|
11
|
+
const SERVER_INFO = {
|
|
12
|
+
name: "workiq-lite",
|
|
13
|
+
version: "0.1.0"
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const DEFAULT_MCP_PORT = 52366;
|
|
17
|
+
const DEFAULT_WS_PORT = 52365;
|
|
18
|
+
const SESSION_IDLE_TTL_MS = 30 * 60 * 1000;
|
|
19
|
+
const SESSION_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
class ToolRegistry {
|
|
22
|
+
constructor() {
|
|
23
|
+
this.tools = [];
|
|
24
|
+
this.handlers = new Map();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
addTool(definition, handler) {
|
|
28
|
+
if (!definition?.name) {
|
|
29
|
+
throw new Error("Tool definition must include name");
|
|
30
|
+
}
|
|
31
|
+
if (this.handlers.has(definition.name)) {
|
|
32
|
+
throw new Error(`Duplicate tool name: ${definition.name}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this.tools.push(definition);
|
|
36
|
+
this.handlers.set(definition.name, handler);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
list() {
|
|
40
|
+
return this.tools;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getHandler(name) {
|
|
44
|
+
return this.handlers.get(name);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createReadHandleCache() {
|
|
49
|
+
const cache = new Map();
|
|
50
|
+
|
|
51
|
+
const interval = setInterval(() => {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
for (const [key, value] of cache.entries()) {
|
|
54
|
+
if (value.expiresAt < now) {
|
|
55
|
+
cache.delete(key);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}, 60_000);
|
|
59
|
+
|
|
60
|
+
interval.unref();
|
|
61
|
+
|
|
62
|
+
return cache;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function loadToolModules(toolRegistry, context) {
|
|
66
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
67
|
+
const toolsDir = path.resolve(currentDir, "tools");
|
|
68
|
+
const files = fs
|
|
69
|
+
.readdirSync(toolsDir)
|
|
70
|
+
.filter((name) => name.endsWith(".js"))
|
|
71
|
+
.sort((a, b) => a.localeCompare(b));
|
|
72
|
+
|
|
73
|
+
for (const fileName of files) {
|
|
74
|
+
const fullPath = path.join(toolsDir, fileName);
|
|
75
|
+
const moduleUrl = pathToFileURL(fullPath).href;
|
|
76
|
+
const mod = await import(moduleUrl);
|
|
77
|
+
|
|
78
|
+
if (typeof mod.registerTools !== "function") {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
mod.registerTools(toolRegistry, context);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toToolSuccessResult(payload) {
|
|
87
|
+
return {
|
|
88
|
+
content: [
|
|
89
|
+
{
|
|
90
|
+
type: "text",
|
|
91
|
+
text: JSON.stringify(payload, null, 2)
|
|
92
|
+
}
|
|
93
|
+
],
|
|
94
|
+
structuredContent: payload
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toToolErrorResult(message) {
|
|
99
|
+
return {
|
|
100
|
+
content: [
|
|
101
|
+
{
|
|
102
|
+
type: "text",
|
|
103
|
+
text: `Error: ${message}`
|
|
104
|
+
}
|
|
105
|
+
],
|
|
106
|
+
isError: true
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function createSessionServer(toolRegistry, _context) {
|
|
111
|
+
const server = new Server(SERVER_INFO, {
|
|
112
|
+
capabilities: {
|
|
113
|
+
tools: {}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
118
|
+
tools: toolRegistry.list()
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
122
|
+
const toolName = request.params.name;
|
|
123
|
+
const handler = toolRegistry.getHandler(toolName);
|
|
124
|
+
|
|
125
|
+
if (!handler) {
|
|
126
|
+
return toToolErrorResult(`Unknown tool: ${toolName}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const args = request.params.arguments || {};
|
|
131
|
+
const result = await handler(args);
|
|
132
|
+
return toToolSuccessResult(result);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return toToolErrorResult(String(err?.message || err));
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return server;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parsePort(value, envName, defaultPort) {
|
|
142
|
+
const port = Number(value || defaultPort);
|
|
143
|
+
if (!Number.isInteger(port) || port <= 0 || port >= 65536) {
|
|
144
|
+
throw new Error(`${envName} must be a valid TCP port number`);
|
|
145
|
+
}
|
|
146
|
+
return port;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sendJsonRpcError(res, status, code, message) {
|
|
150
|
+
res.status(status).json({
|
|
151
|
+
jsonrpc: "2.0",
|
|
152
|
+
error: {
|
|
153
|
+
code,
|
|
154
|
+
message
|
|
155
|
+
},
|
|
156
|
+
id: null
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function main() {
|
|
161
|
+
const mcpPort = parsePort(process.env.WORKIQ_MCP_PORT, "WORKIQ_MCP_PORT", DEFAULT_MCP_PORT);
|
|
162
|
+
const wsPort = parsePort(process.env.WORKIQ_WS_PORT, "WORKIQ_WS_PORT", DEFAULT_WS_PORT);
|
|
163
|
+
|
|
164
|
+
// MCP responses are returned over HTTP; operational logs must go to stderr only.
|
|
165
|
+
const stderrLogger = {
|
|
166
|
+
info: (...args) => console.error(...args),
|
|
167
|
+
warn: (...args) => console.warn(...args),
|
|
168
|
+
error: (...args) => console.error(...args)
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const readHandleCache = createReadHandleCache();
|
|
172
|
+
|
|
173
|
+
const ws = new WsServer({
|
|
174
|
+
host: "127.0.0.1",
|
|
175
|
+
port: wsPort,
|
|
176
|
+
logger: stderrLogger
|
|
177
|
+
});
|
|
178
|
+
await ws.start();
|
|
179
|
+
|
|
180
|
+
const toolRegistry = new ToolRegistry();
|
|
181
|
+
const toolContext = { ws, readHandleCache };
|
|
182
|
+
await loadToolModules(toolRegistry, toolContext);
|
|
183
|
+
|
|
184
|
+
const sessions = new Map();
|
|
185
|
+
const app = createMcpExpressApp({ host: "127.0.0.1" });
|
|
186
|
+
|
|
187
|
+
app.all("/mcp", async (req, res) => {
|
|
188
|
+
const sessionId = req.get("mcp-session-id");
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
if (req.method === "POST" && !sessionId) {
|
|
192
|
+
let initializedSessionId;
|
|
193
|
+
const transport = new StreamableHTTPServerTransport({
|
|
194
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
195
|
+
onsessioninitialized: async (newSessionId) => {
|
|
196
|
+
const server = createSessionServer(toolRegistry, toolContext);
|
|
197
|
+
await server.connect(transport);
|
|
198
|
+
initializedSessionId = newSessionId;
|
|
199
|
+
sessions.set(newSessionId, {
|
|
200
|
+
server,
|
|
201
|
+
transport,
|
|
202
|
+
lastActivity: Date.now()
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
transport.onerror = (err) => {
|
|
208
|
+
stderrLogger.warn("[workiq] MCP transport error:", err);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
transport.onclose = () => {
|
|
212
|
+
const closedSessionId = initializedSessionId || transport.sessionId;
|
|
213
|
+
if (!closedSessionId) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
sessions.delete(closedSessionId);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
await transport.handleRequest(req, res, req.body);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!sessionId) {
|
|
224
|
+
sendJsonRpcError(res, 400, -32000, "Bad Request: Mcp-Session-Id header is required");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const session = sessions.get(sessionId);
|
|
229
|
+
if (!session) {
|
|
230
|
+
sendJsonRpcError(res, 404, -32001, "Session not found");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
session.lastActivity = Date.now();
|
|
235
|
+
await session.transport.handleRequest(req, res, req.body);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
stderrLogger.error("[workiq] failed to handle MCP request:", err);
|
|
238
|
+
if (!res.headersSent) {
|
|
239
|
+
sendJsonRpcError(res, 500, -32603, "Internal error");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const sessionSweepInterval = setInterval(() => {
|
|
245
|
+
const now = Date.now();
|
|
246
|
+
for (const [sessionId, session] of sessions.entries()) {
|
|
247
|
+
if (now - session.lastActivity <= SESSION_IDLE_TTL_MS) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
session.server.close().catch((err) => {
|
|
252
|
+
stderrLogger.warn(`[workiq] failed to close idle session ${sessionId}:`, err);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}, SESSION_SWEEP_INTERVAL_MS);
|
|
256
|
+
sessionSweepInterval.unref();
|
|
257
|
+
|
|
258
|
+
const httpServer = await new Promise((resolve, reject) => {
|
|
259
|
+
const server = app.listen(mcpPort, "127.0.0.1");
|
|
260
|
+
server.once("listening", () => resolve(server));
|
|
261
|
+
server.once("error", reject);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
stderrLogger.error(`[workiq] MCP HTTP listening on http://127.0.0.1:${mcpPort}/mcp`);
|
|
265
|
+
|
|
266
|
+
let shuttingDown = false;
|
|
267
|
+
const shutdown = async () => {
|
|
268
|
+
if (shuttingDown) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
shuttingDown = true;
|
|
272
|
+
|
|
273
|
+
clearInterval(sessionSweepInterval);
|
|
274
|
+
|
|
275
|
+
for (const session of sessions.values()) {
|
|
276
|
+
try {
|
|
277
|
+
await session.server.close();
|
|
278
|
+
} catch (err) {
|
|
279
|
+
stderrLogger.warn("[workiq] failed to close session during shutdown:", err);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
await ws.stop();
|
|
285
|
+
} catch (err) {
|
|
286
|
+
stderrLogger.warn("[workiq] failed to stop WebSocket server:", err);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await new Promise((resolve) => {
|
|
290
|
+
httpServer.close(() => resolve());
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
process.exit(0);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
process.on("SIGINT", shutdown);
|
|
297
|
+
process.on("SIGTERM", shutdown);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
main().catch((err) => {
|
|
301
|
+
console.error("[workiq] MCP server failed to start:", err);
|
|
302
|
+
process.exit(1);
|
|
303
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const SEARCH_TOOL = {
|
|
4
|
+
name: "search",
|
|
5
|
+
description:
|
|
6
|
+
"Search across Microsoft 365 data including emails, files, Teams chats, calendar events, people, and external connectors.",
|
|
7
|
+
inputSchema: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
conversation_id: {
|
|
11
|
+
type: "string",
|
|
12
|
+
description: "Client-provided conversation identifier for grouping related requests"
|
|
13
|
+
},
|
|
14
|
+
query: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "Search query string"
|
|
17
|
+
},
|
|
18
|
+
source: {
|
|
19
|
+
type: "string",
|
|
20
|
+
enum: ["all", "email", "files", "chat", "events", "people", "external"],
|
|
21
|
+
description: "Data source to search",
|
|
22
|
+
default: "all"
|
|
23
|
+
},
|
|
24
|
+
connector: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Specific external connector name when source='external'"
|
|
27
|
+
},
|
|
28
|
+
size: {
|
|
29
|
+
type: "integer",
|
|
30
|
+
minimum: 1,
|
|
31
|
+
maximum: 25,
|
|
32
|
+
description: "Number of results to return",
|
|
33
|
+
default: 10
|
|
34
|
+
},
|
|
35
|
+
from: {
|
|
36
|
+
type: "integer",
|
|
37
|
+
minimum: 0,
|
|
38
|
+
description: "Pagination offset",
|
|
39
|
+
default: 0
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
required: ["query", "conversation_id"]
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const OPEN_TOOL = {
|
|
47
|
+
name: "open",
|
|
48
|
+
description:
|
|
49
|
+
"Open and read best-effort content for a prior search result using its read handle. Returns completeness metadata: full, partial, or snippet.",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
conversation_id: {
|
|
54
|
+
type: "string",
|
|
55
|
+
description: "Client-provided conversation identifier for grouping related requests"
|
|
56
|
+
},
|
|
57
|
+
read_handle: {
|
|
58
|
+
type: "string",
|
|
59
|
+
description: "Opaque read handle from search result"
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
required: ["read_handle", "conversation_id"]
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function asObject(value) {
|
|
67
|
+
if (!value || typeof value !== "object") {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeReadHandle(cache, readContext) {
|
|
74
|
+
const id = crypto.randomUUID();
|
|
75
|
+
cache.set(id, {
|
|
76
|
+
value: readContext,
|
|
77
|
+
expiresAt: Date.now() + 30 * 60 * 1000
|
|
78
|
+
});
|
|
79
|
+
return id;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getReadContext(cache, handle) {
|
|
83
|
+
const record = cache.get(handle);
|
|
84
|
+
if (!record) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (record.expiresAt < Date.now()) {
|
|
89
|
+
cache.delete(handle);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return record.value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function registerTools(registry, context) {
|
|
97
|
+
registry.addTool(SEARCH_TOOL, async (args) => {
|
|
98
|
+
const conversationId = String(args.conversation_id || "").trim();
|
|
99
|
+
if (!conversationId) {
|
|
100
|
+
throw new Error("conversation_id is required");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const query = String(args.query || "").trim();
|
|
104
|
+
if (!query) {
|
|
105
|
+
throw new Error("query is required");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const source = args.source || "all";
|
|
109
|
+
const connector = args.connector ? String(args.connector).trim() : undefined;
|
|
110
|
+
const size = Number.isInteger(args.size) ? args.size : 10;
|
|
111
|
+
const from = Number.isInteger(args.from) ? args.from : 0;
|
|
112
|
+
|
|
113
|
+
const wsResult = await context.ws.sendRequest(
|
|
114
|
+
"search",
|
|
115
|
+
{ conversation_id: conversationId, query, source, connector, size, from },
|
|
116
|
+
{ timeoutMs: 30000 }
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const resultObject = asObject(wsResult);
|
|
120
|
+
const incomingResults = Array.isArray(resultObject.results) ? resultObject.results : [];
|
|
121
|
+
|
|
122
|
+
const transformedResults = incomingResults.map((result, index) => {
|
|
123
|
+
const row = asObject(result);
|
|
124
|
+
const { _readContext: readContextFromRow, ...publicRow } = row;
|
|
125
|
+
const readContext = readContextFromRow || {
|
|
126
|
+
type: publicRow.type,
|
|
127
|
+
id: publicRow.id,
|
|
128
|
+
title: publicRow.title,
|
|
129
|
+
url: publicRow.url,
|
|
130
|
+
snippet: publicRow.snippet,
|
|
131
|
+
metadata: {}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const readHandle = makeReadHandle(context.readHandleCache, readContext);
|
|
135
|
+
|
|
136
|
+
// Only expose the opaque handle to agents; never return internal read context.
|
|
137
|
+
return {
|
|
138
|
+
index: Number.isInteger(publicRow.index) ? publicRow.index : index,
|
|
139
|
+
type: publicRow.type || "",
|
|
140
|
+
title: publicRow.title || "",
|
|
141
|
+
url: publicRow.url || "",
|
|
142
|
+
contentSource: publicRow.contentSource || "",
|
|
143
|
+
snippet: publicRow.snippet || "",
|
|
144
|
+
date: publicRow.date || "",
|
|
145
|
+
from: publicRow.from,
|
|
146
|
+
to: publicRow.to,
|
|
147
|
+
subject: publicRow.subject,
|
|
148
|
+
teamName: publicRow.teamName,
|
|
149
|
+
channelName: publicRow.channelName,
|
|
150
|
+
preview: publicRow.preview,
|
|
151
|
+
author: publicRow.author,
|
|
152
|
+
fileType: publicRow.fileType,
|
|
153
|
+
location: publicRow.location,
|
|
154
|
+
startTime: publicRow.startTime,
|
|
155
|
+
endTime: publicRow.endTime,
|
|
156
|
+
attendees: publicRow.attendees,
|
|
157
|
+
organizer: publicRow.organizer,
|
|
158
|
+
jobTitle: publicRow.jobTitle,
|
|
159
|
+
department: publicRow.department,
|
|
160
|
+
email: publicRow.email,
|
|
161
|
+
_readHandle: readHandle
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
results: transformedResults,
|
|
167
|
+
totalEstimatedMatches: Number(resultObject.totalEstimatedMatches || 0),
|
|
168
|
+
moreAvailable: Boolean(resultObject.moreAvailable),
|
|
169
|
+
nextFrom:
|
|
170
|
+
Number.isInteger(resultObject.nextFrom)
|
|
171
|
+
? resultObject.nextFrom
|
|
172
|
+
: from + transformedResults.length,
|
|
173
|
+
warnings: Array.isArray(resultObject.warnings) ? resultObject.warnings : []
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
registry.addTool(OPEN_TOOL, async (args) => {
|
|
178
|
+
const conversationId = String(args.conversation_id || "").trim();
|
|
179
|
+
if (!conversationId) {
|
|
180
|
+
throw new Error("conversation_id is required");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const readHandle = String(args.read_handle || "").trim();
|
|
184
|
+
if (!readHandle) {
|
|
185
|
+
throw new Error("read_handle is required");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const readContext = getReadContext(context.readHandleCache, readHandle);
|
|
189
|
+
if (!readContext) {
|
|
190
|
+
throw new Error("Invalid or expired read_handle. Run search again.");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const wsResult = await context.ws.sendRequest(
|
|
194
|
+
"open",
|
|
195
|
+
{ conversation_id: conversationId, context: readContext },
|
|
196
|
+
{ timeoutMs: 60000 }
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
return asObject(wsResult);
|
|
200
|
+
});
|
|
201
|
+
}
|
package/src/ws-server.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
3
|
+
|
|
4
|
+
export class WsServer {
|
|
5
|
+
constructor(options = {}) {
|
|
6
|
+
const stderrLogger = {
|
|
7
|
+
info: (...args) => console.error(...args),
|
|
8
|
+
warn: (...args) => console.warn(...args),
|
|
9
|
+
error: (...args) => console.error(...args)
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
this.host = options.host || "127.0.0.1";
|
|
13
|
+
this.port = Number(options.port || 52365);
|
|
14
|
+
this.logger = options.logger || stderrLogger;
|
|
15
|
+
|
|
16
|
+
this.wss = null;
|
|
17
|
+
this.client = null;
|
|
18
|
+
this.pending = new Map();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async start() {
|
|
22
|
+
if (this.wss) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.wss = new WebSocketServer({
|
|
27
|
+
host: this.host,
|
|
28
|
+
port: this.port
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
this.wss.on("connection", (socket, req) => {
|
|
32
|
+
this.handleConnection(socket, req).catch((err) => {
|
|
33
|
+
this.logger.error("[workiq/ws] connection handling failed:", err);
|
|
34
|
+
try {
|
|
35
|
+
socket.close(1011, "internal error");
|
|
36
|
+
} catch (_err) {
|
|
37
|
+
// ignored
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await new Promise((resolve, reject) => {
|
|
43
|
+
this.wss.once("listening", resolve);
|
|
44
|
+
this.wss.once("error", reject);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
this.logger.error(`[workiq/ws] listening on ws://${this.host}:${this.port}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async stop() {
|
|
51
|
+
for (const pending of this.pending.values()) {
|
|
52
|
+
clearTimeout(pending.timeout);
|
|
53
|
+
pending.reject(new Error("WebSocket server stopped"));
|
|
54
|
+
}
|
|
55
|
+
this.pending.clear();
|
|
56
|
+
|
|
57
|
+
if (this.client) {
|
|
58
|
+
try {
|
|
59
|
+
this.client.close(1001, "server stopping");
|
|
60
|
+
} catch (_err) {
|
|
61
|
+
// ignored
|
|
62
|
+
}
|
|
63
|
+
this.client = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!this.wss) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const wss = this.wss;
|
|
71
|
+
this.wss = null;
|
|
72
|
+
await new Promise((resolve) => wss.close(resolve));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
isReady() {
|
|
76
|
+
return Boolean(this.client && this.client.readyState === WebSocket.OPEN);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getStatus() {
|
|
80
|
+
return {
|
|
81
|
+
host: this.host,
|
|
82
|
+
port: this.port,
|
|
83
|
+
connected: this.isReady()
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async sendRequest(type, params, options = {}) {
|
|
88
|
+
const timeoutMs = Number(options.timeoutMs || 30000);
|
|
89
|
+
|
|
90
|
+
if (!this.isReady()) {
|
|
91
|
+
throw new Error("M365 Connector extension is not connected");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const id = crypto.randomUUID();
|
|
95
|
+
const payload = { id, type, params: params || {} };
|
|
96
|
+
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const timeout = setTimeout(() => {
|
|
99
|
+
this.pending.delete(id);
|
|
100
|
+
reject(new Error(`WebSocket request timed out after ${timeoutMs}ms`));
|
|
101
|
+
}, timeoutMs);
|
|
102
|
+
|
|
103
|
+
this.pending.set(id, { resolve, reject, timeout });
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
this.client.send(JSON.stringify(payload));
|
|
107
|
+
} catch (err) {
|
|
108
|
+
clearTimeout(timeout);
|
|
109
|
+
this.pending.delete(id);
|
|
110
|
+
reject(err);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async handleConnection(socket, req) {
|
|
116
|
+
const origin = req.headers.origin;
|
|
117
|
+
|
|
118
|
+
if (!origin) {
|
|
119
|
+
this.logger.warn("[workiq/ws] rejected connection: missing origin header");
|
|
120
|
+
socket.close(1008, "origin required");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!origin.startsWith("chrome-extension://")) {
|
|
125
|
+
this.logger.warn(`[workiq/ws] rejected connection from origin: ${origin}`);
|
|
126
|
+
socket.close(1008, "origin not allowed");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (this.client && this.client.readyState === WebSocket.OPEN) {
|
|
131
|
+
// MV3 service workers can die silently without sending a close frame.
|
|
132
|
+
// When the extension reconnects, replace the stale connection.
|
|
133
|
+
this.logger.error("[workiq/ws] replacing existing connection with new one");
|
|
134
|
+
try { this.client.terminate(); } catch (_e) { /* ignored */ }
|
|
135
|
+
this.handleClose(this.client);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Clean up if client ref exists but is no longer OPEN
|
|
139
|
+
if (this.client && this.client.readyState !== WebSocket.OPEN) {
|
|
140
|
+
this.client = null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.client = socket;
|
|
144
|
+
|
|
145
|
+
socket.on("message", async (rawData) => {
|
|
146
|
+
await this.handleMessage(socket, rawData);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
socket.on("close", (code, reason) => {
|
|
150
|
+
this.logger.error(`[workiq/ws] socket close: code=${code}, reason=${String(reason)}`);
|
|
151
|
+
this.handleClose(socket);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
socket.on("error", (err) => {
|
|
155
|
+
this.logger.warn("[workiq/ws] client socket error:", err.message);
|
|
156
|
+
});
|
|
157
|
+
this.logger.error("[workiq/ws] extension connected");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async handleMessage(socket, rawData) {
|
|
161
|
+
let message;
|
|
162
|
+
try {
|
|
163
|
+
message = JSON.parse(String(rawData));
|
|
164
|
+
} catch (_err) {
|
|
165
|
+
socket.close(4002, "invalid json");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!message?.id) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const pending = this.pending.get(message.id);
|
|
174
|
+
if (!pending) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
clearTimeout(pending.timeout);
|
|
179
|
+
this.pending.delete(message.id);
|
|
180
|
+
|
|
181
|
+
if (!message.success) {
|
|
182
|
+
pending.reject(new Error(message.error || "Unknown extension error"));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
pending.resolve(message.data);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
handleClose(socket) {
|
|
190
|
+
if (this.client === socket) {
|
|
191
|
+
this.client = null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const [id, pending] of this.pending.entries()) {
|
|
195
|
+
clearTimeout(pending.timeout);
|
|
196
|
+
pending.reject(new Error("Extension disconnected"));
|
|
197
|
+
this.pending.delete(id);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
this.logger.error("[workiq/ws] extension disconnected");
|
|
201
|
+
}
|
|
202
|
+
}
|