imessage-mcp-server 1.0.0 → 2.0.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 +159 -79
- package/README.zh.md +310 -0
- package/bin/imessage-mcp-server +4 -0
- package/package.json +18 -8
- package/scripts/install-launchagent.sh +98 -0
- package/src/bridge/daemon.js +243 -0
- package/src/bridge/index.js +31 -0
- package/src/bridge/llm-loop.js +232 -0
- package/src/bridge/mcp-client.js +100 -0
- package/src/cli.js +125 -0
- package/src/config.js +236 -0
- package/src/providers/anthropic.js +80 -0
- package/src/providers/base.js +37 -0
- package/src/providers/index.js +19 -0
- package/src/providers/openai.js +104 -0
- package/src/safety.js +58 -0
- package/src/server/index.js +45 -0
- package/src/server/tools.js +163 -0
- package/src/shared/constants.js +30 -0
- package/src/shared/imessage.js +277 -0
- package/src/shared/utils.js +54 -0
- package/index.js +0 -480
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
import { LOG } from "../shared/utils.js";
|
|
4
|
+
|
|
5
|
+
export class McpClientManager {
|
|
6
|
+
constructor(mcpServersConfig = {}) {
|
|
7
|
+
this.config = mcpServersConfig;
|
|
8
|
+
this.clients = new Map(); // serverId -> { client, transport, tools }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async connectAll() {
|
|
12
|
+
const entries = Object.entries(this.config);
|
|
13
|
+
if (entries.length === 0) {
|
|
14
|
+
LOG.info("No MCP servers configured");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
await Promise.all(
|
|
19
|
+
entries.map(([serverId, serverConfig]) =>
|
|
20
|
+
this.connectServer(serverId, serverConfig)
|
|
21
|
+
)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async connectServer(serverId, serverConfig) {
|
|
26
|
+
try {
|
|
27
|
+
const transport = new StdioClientTransport({
|
|
28
|
+
command: serverConfig.command,
|
|
29
|
+
args: serverConfig.args || [],
|
|
30
|
+
env: { ...process.env, ...(serverConfig.env || {}) },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const client = new Client(
|
|
34
|
+
{
|
|
35
|
+
name: "imessage-bridge",
|
|
36
|
+
version: "2.0.0",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
capabilities: {},
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
await client.connect(transport);
|
|
44
|
+
const toolsResult = await client.listTools();
|
|
45
|
+
const tools = toolsResult.tools || [];
|
|
46
|
+
|
|
47
|
+
this.clients.set(serverId, { client, transport, tools });
|
|
48
|
+
LOG.info(`MCP server connected`, { serverId, tools: tools.length });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
LOG.error(`Failed to connect MCP server ${serverId}`, {
|
|
51
|
+
error: err.message,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getAllTools() {
|
|
57
|
+
const all = [];
|
|
58
|
+
for (const [serverId, { tools }] of this.clients) {
|
|
59
|
+
for (const t of tools) {
|
|
60
|
+
all.push({
|
|
61
|
+
...t,
|
|
62
|
+
serverId,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return all;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async callTool(name, input) {
|
|
70
|
+
for (const [serverId, { client, tools }] of this.clients) {
|
|
71
|
+
const found = tools.find((t) => t.name === name);
|
|
72
|
+
if (found) {
|
|
73
|
+
LOG.info(`Calling MCP tool`, { serverId, name });
|
|
74
|
+
const result = await client.callTool({ name, arguments: input });
|
|
75
|
+
return this.extractToolResult(result);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`Tool ${name} not found in any connected MCP server`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
extractToolResult(result) {
|
|
82
|
+
if (!result || !result.content) return "";
|
|
83
|
+
return result.content
|
|
84
|
+
.map((c) => {
|
|
85
|
+
if (c.type === "text") return c.text;
|
|
86
|
+
if (c.type === "image") return "[image]";
|
|
87
|
+
return JSON.stringify(c);
|
|
88
|
+
})
|
|
89
|
+
.join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async close() {
|
|
93
|
+
await Promise.all(
|
|
94
|
+
Array.from(this.clients.values()).map(({ client }) =
|
|
95
|
+
client.close().catch(() => {})
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
this.clients.clear();
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
import { parseArgs } from "./config.js";
|
|
6
|
+
import { startServer } from "./server/index.js";
|
|
7
|
+
import { startBridge } from "./bridge/index.js";
|
|
8
|
+
import { LOG } from "./shared/utils.js";
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const PKG_PATH = path.join(__dirname, "..", "package.json");
|
|
12
|
+
const INSTALL_SCRIPT = path.join(__dirname, "..", "scripts", "install-launchagent.sh");
|
|
13
|
+
|
|
14
|
+
function showHelp() {
|
|
15
|
+
console.log(`
|
|
16
|
+
imessage-mcp-server — MCP server and AI bridge for iMessage on macOS
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
npx imessage-mcp-server --server Start the MCP server (stdio)
|
|
20
|
+
npx imessage-mcp-server --bridge --config ... Start the bridge daemon
|
|
21
|
+
npx imessage-mcp-server --help Show this help
|
|
22
|
+
npx imessage-mcp-server --version Show version
|
|
23
|
+
|
|
24
|
+
MCP Server mode:
|
|
25
|
+
--server Start as MCP Server
|
|
26
|
+
|
|
27
|
+
Bridge mode:
|
|
28
|
+
--bridge Start as AI bridge daemon
|
|
29
|
+
--config <path> Path to bridge-config.json
|
|
30
|
+
--foreground Run in foreground (don't daemonize)
|
|
31
|
+
--test-config Validate config and MCP connections
|
|
32
|
+
--install-service Install as macOS LaunchAgent
|
|
33
|
+
--uninstall Uninstall LaunchAgent
|
|
34
|
+
--status Check LaunchAgent status
|
|
35
|
+
|
|
36
|
+
Bridge options (override config file):
|
|
37
|
+
--master-handle <handle> Your iMessage handle
|
|
38
|
+
--provider <anthropic|openai> LLM provider
|
|
39
|
+
--api-key <key> API key
|
|
40
|
+
--base-url <url> Custom API base URL
|
|
41
|
+
--model <model> Model name
|
|
42
|
+
|
|
43
|
+
Environment variables:
|
|
44
|
+
IMESSAGE_DB_PATH Path to chat.db
|
|
45
|
+
ANTHROPIC_API_KEY / OPENAI_API_KEY API keys
|
|
46
|
+
`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function showVersion() {
|
|
50
|
+
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, "utf-8"));
|
|
51
|
+
console.log(pkg.version);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function runInstallScript(action, configPath) {
|
|
55
|
+
if (!fs.existsSync(INSTALL_SCRIPT)) {
|
|
56
|
+
console.error("Install script not found:", INSTALL_SCRIPT);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
const parts = [INSTALL_SCRIPT, action];
|
|
60
|
+
if (configPath) parts.push(configPath);
|
|
61
|
+
const quoted = parts.map((p) => `"${p.replace(/"/g, '\\"')}"`).join(" ");
|
|
62
|
+
execSync(`bash ${quoted}`, { stdio: "inherit" });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
66
|
+
const args = parseArgs(argv);
|
|
67
|
+
|
|
68
|
+
if (args.help) {
|
|
69
|
+
showHelp();
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (args.version) {
|
|
74
|
+
showVersion();
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Platform check
|
|
79
|
+
if (process.platform !== "darwin") {
|
|
80
|
+
console.error(
|
|
81
|
+
"❌ iMessage MCP only supports macOS (detected: " + process.platform + ")"
|
|
82
|
+
);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// osascript check
|
|
87
|
+
try {
|
|
88
|
+
execSync("which osascript", { encoding: "utf-8", stdio: "pipe" });
|
|
89
|
+
} catch {
|
|
90
|
+
console.error("❌ osascript not found. This tool requires macOS.");
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Default to help when no arguments provided
|
|
95
|
+
if (!args.mode && argv.length === 0) {
|
|
96
|
+
showHelp();
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
if (args.mode === "server") {
|
|
102
|
+
await startServer();
|
|
103
|
+
} else if (args.mode === "bridge") {
|
|
104
|
+
if (args.installService) {
|
|
105
|
+
runInstallScript("load", args.configPath);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (args.uninstall) {
|
|
109
|
+
runInstallScript("unload", args.configPath);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (args.status) {
|
|
113
|
+
runInstallScript("status", args.configPath);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
await startBridge(args);
|
|
117
|
+
} else {
|
|
118
|
+
console.error("Unknown mode. Use --server or --bridge.");
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
LOG.error("Fatal error", { error: err.message, stack: err.stack });
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { homedir, hostname } from "os";
|
|
4
|
+
import { DEFAULTS } from "./shared/constants.js";
|
|
5
|
+
|
|
6
|
+
function resolveEnvRefs(value) {
|
|
7
|
+
if (typeof value !== "string") return value;
|
|
8
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function deepResolveEnv(obj) {
|
|
12
|
+
if (typeof obj === "string") return resolveEnvRefs(obj);
|
|
13
|
+
if (Array.isArray(obj)) return obj.map(deepResolveEnv);
|
|
14
|
+
if (obj && typeof obj === "object") {
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
17
|
+
out[k] = deepResolveEnv(v);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
return obj;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseArgs(argv) {
|
|
25
|
+
const args = {
|
|
26
|
+
mode: null, // 'server' | 'bridge'
|
|
27
|
+
configPath: null,
|
|
28
|
+
foreground: false,
|
|
29
|
+
testConfig: false,
|
|
30
|
+
installService: false,
|
|
31
|
+
uninstall: false,
|
|
32
|
+
status: false,
|
|
33
|
+
help: false,
|
|
34
|
+
version: false,
|
|
35
|
+
masterHandle: null,
|
|
36
|
+
provider: null,
|
|
37
|
+
apiKey: null,
|
|
38
|
+
baseUrl: null,
|
|
39
|
+
model: null,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43
|
+
const arg = argv[i];
|
|
44
|
+
switch (arg) {
|
|
45
|
+
case "--server":
|
|
46
|
+
args.mode = "server";
|
|
47
|
+
break;
|
|
48
|
+
case "--bridge":
|
|
49
|
+
args.mode = "bridge";
|
|
50
|
+
break;
|
|
51
|
+
case "--config":
|
|
52
|
+
args.configPath = argv[++i];
|
|
53
|
+
break;
|
|
54
|
+
case "--foreground":
|
|
55
|
+
args.foreground = true;
|
|
56
|
+
break;
|
|
57
|
+
case "--test-config":
|
|
58
|
+
args.testConfig = true;
|
|
59
|
+
break;
|
|
60
|
+
case "--install-service":
|
|
61
|
+
args.installService = true;
|
|
62
|
+
break;
|
|
63
|
+
case "--uninstall":
|
|
64
|
+
args.uninstall = true;
|
|
65
|
+
break;
|
|
66
|
+
case "--status":
|
|
67
|
+
args.status = true;
|
|
68
|
+
break;
|
|
69
|
+
case "--help":
|
|
70
|
+
case "-h":
|
|
71
|
+
args.help = true;
|
|
72
|
+
break;
|
|
73
|
+
case "--version":
|
|
74
|
+
case "-v":
|
|
75
|
+
args.version = true;
|
|
76
|
+
break;
|
|
77
|
+
case "--master-handle":
|
|
78
|
+
args.masterHandle = argv[++i];
|
|
79
|
+
break;
|
|
80
|
+
case "--provider":
|
|
81
|
+
args.provider = argv[++i];
|
|
82
|
+
break;
|
|
83
|
+
case "--api-key":
|
|
84
|
+
args.apiKey = argv[++i];
|
|
85
|
+
break;
|
|
86
|
+
case "--base-url":
|
|
87
|
+
args.baseUrl = argv[++i];
|
|
88
|
+
break;
|
|
89
|
+
case "--model":
|
|
90
|
+
args.model = argv[++i];
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return args;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function loadConfigFile(configPath) {
|
|
99
|
+
if (!configPath) return {};
|
|
100
|
+
const resolved = path.resolve(configPath);
|
|
101
|
+
if (!fs.existsSync(resolved)) {
|
|
102
|
+
throw new Error(`Config file not found: ${resolved}`);
|
|
103
|
+
}
|
|
104
|
+
const raw = JSON.parse(fs.readFileSync(resolved, "utf-8"));
|
|
105
|
+
return deepResolveEnv(raw);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function buildBridgeConfig(args) {
|
|
109
|
+
const fileConfig = loadConfigFile(args.configPath) || {};
|
|
110
|
+
|
|
111
|
+
// Try reading Claude Code settings for env fallbacks
|
|
112
|
+
let claudeEnv = {};
|
|
113
|
+
let claudeModel = null;
|
|
114
|
+
try {
|
|
115
|
+
const settingsPath = path.join(homedir(), ".claude/settings.json");
|
|
116
|
+
if (fs.existsSync(settingsPath)) {
|
|
117
|
+
const raw = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
118
|
+
claudeEnv = raw.env || {};
|
|
119
|
+
claudeModel = raw.model || null;
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// ignore
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const cfg = {
|
|
126
|
+
masterHandle:
|
|
127
|
+
args.masterHandle ||
|
|
128
|
+
fileConfig.masterHandle ||
|
|
129
|
+
process.env.IMESSAGE_MASTER_HANDLE,
|
|
130
|
+
provider:
|
|
131
|
+
args.provider ||
|
|
132
|
+
fileConfig.provider ||
|
|
133
|
+
process.env.IMESSAGE_PROVIDER ||
|
|
134
|
+
"anthropic",
|
|
135
|
+
apiKey:
|
|
136
|
+
args.apiKey ||
|
|
137
|
+
fileConfig.apiKey ||
|
|
138
|
+
process.env.ANTHROPIC_API_KEY ||
|
|
139
|
+
process.env.ANTHROPIC_AUTH_TOKEN ||
|
|
140
|
+
process.env.OPENAI_API_KEY ||
|
|
141
|
+
claudeEnv.ANTHROPIC_AUTH_TOKEN ||
|
|
142
|
+
claudeEnv.ANTHROPIC_API_KEY ||
|
|
143
|
+
claudeEnv.OPENAI_API_KEY,
|
|
144
|
+
baseUrl:
|
|
145
|
+
args.baseUrl ||
|
|
146
|
+
fileConfig.baseUrl ||
|
|
147
|
+
process.env.ANTHROPIC_BASE_URL ||
|
|
148
|
+
process.env.OPENAI_BASE_URL ||
|
|
149
|
+
claudeEnv.ANTHROPIC_BASE_URL ||
|
|
150
|
+
claudeEnv.OPENAI_BASE_URL,
|
|
151
|
+
model:
|
|
152
|
+
args.model ||
|
|
153
|
+
fileConfig.model ||
|
|
154
|
+
process.env.ANTHROPIC_MODEL ||
|
|
155
|
+
process.env.OPENAI_MODEL ||
|
|
156
|
+
claudeEnv.ANTHROPIC_MODEL ||
|
|
157
|
+
claudeEnv.ANTHROPIC_DEFAULT_SONNET_MODEL ||
|
|
158
|
+
claudeEnv.ANTHROPIC_DEFAULT_OPUS_MODEL ||
|
|
159
|
+
claudeEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL ||
|
|
160
|
+
claudeModel || "claude-3-5-sonnet-20241022",
|
|
161
|
+
maxTokens:
|
|
162
|
+
fileConfig.maxTokens ||
|
|
163
|
+
parseInt(process.env.IMESSAGE_MAX_TOKENS, 10) ||
|
|
164
|
+
DEFAULTS.BRIDGE.maxTokens,
|
|
165
|
+
pollIntervalMs:
|
|
166
|
+
fileConfig.pollIntervalMs ||
|
|
167
|
+
parseInt(process.env.IMESSAGE_POLL_INTERVAL_MS, 10) ||
|
|
168
|
+
DEFAULTS.BRIDGE.pollIntervalMs,
|
|
169
|
+
maxHistoryPerConversation:
|
|
170
|
+
fileConfig.maxHistoryPerConversation ||
|
|
171
|
+
DEFAULTS.BRIDGE.maxHistoryPerConversation,
|
|
172
|
+
maxToolIterations:
|
|
173
|
+
fileConfig.maxToolIterations || DEFAULTS.BRIDGE.maxToolIterations,
|
|
174
|
+
sendProcessingIndicator:
|
|
175
|
+
fileConfig.sendProcessingIndicator !== undefined
|
|
176
|
+
? fileConfig.sendProcessingIndicator
|
|
177
|
+
: DEFAULTS.BRIDGE.sendProcessingIndicator,
|
|
178
|
+
systemPrompt:
|
|
179
|
+
fileConfig.systemPrompt || buildDefaultSystemPrompt(fileConfig.projectDir || homedir()),
|
|
180
|
+
mcpServers: fileConfig.mcpServers || {},
|
|
181
|
+
safety: {
|
|
182
|
+
requireConfirmation: false,
|
|
183
|
+
allowedTools: null,
|
|
184
|
+
blockedTools: [],
|
|
185
|
+
blockedCommands: ["rm -rf /", "sudo"],
|
|
186
|
+
readOnly: false,
|
|
187
|
+
...fileConfig.safety,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
if (fileConfig.thinking) {
|
|
192
|
+
cfg.thinking = fileConfig.thinking;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return cfg;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function buildDefaultSystemPrompt(projectDir) {
|
|
199
|
+
const project = projectDir || "/Users/USER/Documents/project";
|
|
200
|
+
return `你是运行在用户 Mac 电脑上的 AI 助手,通过 iMessage 与用户沟通。
|
|
201
|
+
|
|
202
|
+
## 核心能力
|
|
203
|
+
- 执行 Shell 命令来管理项目、查询系统状态、运行脚本等
|
|
204
|
+
- 读取和浏览文件系统中的文件
|
|
205
|
+
- 智能回答问题、提供技术建议和编程帮助
|
|
206
|
+
- 使用中文与用户交流
|
|
207
|
+
|
|
208
|
+
## 重要规则
|
|
209
|
+
- 保持回复简洁、有用。iMessage 有长度限制,长回复请分成多条发送。
|
|
210
|
+
- 如果用户的请求涉及项目代码,先用工具了解项目结构再回答。
|
|
211
|
+
- 对于不确定的内容,诚实告知用户,不要编造信息。
|
|
212
|
+
- 执行有风险的操作前(如删除文件、修改配置),先提醒用户。
|
|
213
|
+
- 工具箱中的 \`send_long_reply\` 用于发送超过一条 iMessage 的长回复,自动分多条发送。
|
|
214
|
+
|
|
215
|
+
## 运行环境
|
|
216
|
+
- 主机名: ${hostname()}
|
|
217
|
+
- 用户目录: ${homedir()}
|
|
218
|
+
- 当前项目目录: ${project}
|
|
219
|
+
`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function validateBridgeConfig(cfg) {
|
|
223
|
+
if (!cfg.masterHandle) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
"masterHandle is required. Set it via --master-handle, config file, or IMESSAGE_MASTER_HANDLE env."
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (!cfg.apiKey) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
"apiKey is required. Set it via --api-key, config file, or ANTHROPIC_API_KEY/OPENAI_API_KEY env."
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (!cfg.model) {
|
|
234
|
+
throw new Error("model is required.");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Anthropic } from "@anthropic-ai/sdk";
|
|
2
|
+
import { BaseProvider, mcpToAnthropicTools } from "./base.js";
|
|
3
|
+
|
|
4
|
+
export class AnthropicProvider extends BaseProvider {
|
|
5
|
+
constructor(config) {
|
|
6
|
+
super(config);
|
|
7
|
+
this.client = new Anthropic({
|
|
8
|
+
apiKey: config.apiKey,
|
|
9
|
+
baseURL: config.baseUrl,
|
|
10
|
+
maxRetries: 3,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
normalizeMessages(messages) {
|
|
15
|
+
// Anthropic requires alternating user/assistant roles.
|
|
16
|
+
// We collapse consecutive same-role blocks.
|
|
17
|
+
const normalized = [];
|
|
18
|
+
for (const m of messages) {
|
|
19
|
+
if (normalized.length === 0) {
|
|
20
|
+
normalized.push({ ...m });
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const last = normalized[normalized.length - 1];
|
|
24
|
+
if (last.role === m.role) {
|
|
25
|
+
// Merge content arrays
|
|
26
|
+
last.content = last.content.concat(m.content);
|
|
27
|
+
} else {
|
|
28
|
+
normalized.push({ ...m });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return normalized;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async chat(messages, tools) {
|
|
35
|
+
const requestPayload = {
|
|
36
|
+
model: this.config.model,
|
|
37
|
+
max_tokens: this.config.maxTokens || 4096,
|
|
38
|
+
system: this.config.systemPrompt || undefined,
|
|
39
|
+
messages: this.normalizeMessages(messages),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (tools.length > 0) {
|
|
43
|
+
requestPayload.tools = mcpToAnthropicTools(tools);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (this.config.thinking?.enabled) {
|
|
47
|
+
requestPayload.thinking = {
|
|
48
|
+
type: "enabled",
|
|
49
|
+
budget_tokens: this.config.thinking.budgetTokens || 1024,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const response = await this.client.messages.create(requestPayload);
|
|
54
|
+
|
|
55
|
+
const result = {
|
|
56
|
+
text: "",
|
|
57
|
+
thinking: [],
|
|
58
|
+
toolCalls: [],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (const block of response.content) {
|
|
62
|
+
if (block.type === "text") {
|
|
63
|
+
result.text = block.text;
|
|
64
|
+
} else if (block.type === "thinking") {
|
|
65
|
+
result.thinking.push({
|
|
66
|
+
thinking: block.thinking || "",
|
|
67
|
+
signature: block.signature,
|
|
68
|
+
});
|
|
69
|
+
} else if (block.type === "tool_use") {
|
|
70
|
+
result.toolCalls.push({
|
|
71
|
+
id: block.id,
|
|
72
|
+
name: block.name,
|
|
73
|
+
input: block.input || {},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function mcpToAnthropicTools(mcpTools) {
|
|
2
|
+
return mcpTools.map((t) => ({
|
|
3
|
+
name: t.name,
|
|
4
|
+
description: t.description,
|
|
5
|
+
input_schema: t.inputSchema || { type: "object", properties: {} },
|
|
6
|
+
}));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function mcpToOpenAITools(mcpTools) {
|
|
10
|
+
return mcpTools.map((t) => ({
|
|
11
|
+
type: "function",
|
|
12
|
+
function: {
|
|
13
|
+
name: t.name,
|
|
14
|
+
description: t.description,
|
|
15
|
+
parameters: t.inputSchema || { type: "object", properties: {} },
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function openAIToolCallToMcp(toolCall) {
|
|
21
|
+
return {
|
|
22
|
+
id: toolCall.id,
|
|
23
|
+
name: toolCall.function.name,
|
|
24
|
+
input: JSON.parse(toolCall.function.arguments || "{}"),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class BaseProvider {
|
|
29
|
+
constructor(config) {
|
|
30
|
+
this.config = config;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// eslint-disable-next-line no-unused-vars
|
|
34
|
+
async chat(messages, tools) {
|
|
35
|
+
throw new Error("Not implemented");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { AnthropicProvider } from "./anthropic.js";
|
|
2
|
+
import { OpenAIProvider } from "./openai.js";
|
|
3
|
+
|
|
4
|
+
export function createProvider(config) {
|
|
5
|
+
const provider = config.provider || "anthropic";
|
|
6
|
+
switch (provider.toLowerCase()) {
|
|
7
|
+
case "anthropic":
|
|
8
|
+
case "claude":
|
|
9
|
+
return new AnthropicProvider(config);
|
|
10
|
+
case "openai":
|
|
11
|
+
case "deepseek":
|
|
12
|
+
case "kimi":
|
|
13
|
+
return new OpenAIProvider(config);
|
|
14
|
+
default:
|
|
15
|
+
throw new Error(`Unsupported provider: ${provider}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { AnthropicProvider, OpenAIProvider };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { BaseProvider, mcpToOpenAITools, openAIToolCallToMcp } from "./base.js";
|
|
3
|
+
|
|
4
|
+
export class OpenAIProvider extends BaseProvider {
|
|
5
|
+
constructor(config) {
|
|
6
|
+
super(config);
|
|
7
|
+
this.client = new OpenAI({
|
|
8
|
+
apiKey: config.apiKey,
|
|
9
|
+
baseURL: config.baseUrl,
|
|
10
|
+
maxRetries: 3,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
normalizeMessages(messages) {
|
|
15
|
+
// OpenAI expects messages array with system/user/assistant/tool roles.
|
|
16
|
+
// For tool results, Anthropic-style tool_result blocks must be converted.
|
|
17
|
+
const normalized = [];
|
|
18
|
+
for (const m of messages) {
|
|
19
|
+
if (typeof m.content === "string") {
|
|
20
|
+
normalized.push({ role: m.role, content: m.content });
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// content is array of blocks
|
|
25
|
+
const textBlocks = [];
|
|
26
|
+
const toolCalls = [];
|
|
27
|
+
const toolResults = [];
|
|
28
|
+
|
|
29
|
+
for (const block of m.content) {
|
|
30
|
+
if (block.type === "text") {
|
|
31
|
+
textBlocks.push(block.text);
|
|
32
|
+
} else if (block.type === "tool_use") {
|
|
33
|
+
toolCalls.push({
|
|
34
|
+
id: block.id,
|
|
35
|
+
type: "function",
|
|
36
|
+
function: {
|
|
37
|
+
name: block.name,
|
|
38
|
+
arguments: JSON.stringify(block.input || {}),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
} else if (block.type === "tool_result") {
|
|
42
|
+
toolResults.push({
|
|
43
|
+
role: "tool",
|
|
44
|
+
tool_call_id: block.tool_use_id,
|
|
45
|
+
content:
|
|
46
|
+
typeof block.content === "string"
|
|
47
|
+
? block.content
|
|
48
|
+
: JSON.stringify(block.content),
|
|
49
|
+
});
|
|
50
|
+
} else if (block.type === "thinking") {
|
|
51
|
+
// OpenAI-compatible endpoints usually ignore thinking blocks;
|
|
52
|
+
// include as text if the model supports it.
|
|
53
|
+
textBlocks.push(`[thinking] ${block.thinking || ""}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (toolCalls.length > 0) {
|
|
58
|
+
normalized.push({
|
|
59
|
+
role: m.role,
|
|
60
|
+
tool_calls: toolCalls,
|
|
61
|
+
content: textBlocks.length > 0 ? textBlocks.join("\n") : null,
|
|
62
|
+
});
|
|
63
|
+
} else if (textBlocks.length > 0) {
|
|
64
|
+
normalized.push({ role: m.role, content: textBlocks.join("\n") });
|
|
65
|
+
}
|
|
66
|
+
for (const tr of toolResults) {
|
|
67
|
+
normalized.push(tr);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return normalized;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async chat(messages, tools) {
|
|
74
|
+
const normalizedMessages = this.normalizeMessages(messages);
|
|
75
|
+
if (this.config.systemPrompt) {
|
|
76
|
+
normalizedMessages.unshift({
|
|
77
|
+
role: "system",
|
|
78
|
+
content: this.config.systemPrompt,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const requestPayload = {
|
|
83
|
+
model: this.config.model,
|
|
84
|
+
max_tokens: this.config.maxTokens || 4096,
|
|
85
|
+
messages: normalizedMessages,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (tools.length > 0) {
|
|
89
|
+
requestPayload.tools = mcpToOpenAITools(tools);
|
|
90
|
+
requestPayload.tool_choice = "auto";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const response = await this.client.chat.completions.create(requestPayload);
|
|
94
|
+
const choice = response.choices[0];
|
|
95
|
+
|
|
96
|
+
const result = {
|
|
97
|
+
text: choice.message.content || "",
|
|
98
|
+
thinking: [],
|
|
99
|
+
toolCalls: (choice.message.tool_calls || []).map(openAIToolCallToMcp),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
}
|