ashlrcode 1.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/LICENSE +21 -0
- package/README.md +295 -0
- package/package.json +46 -0
- package/src/__tests__/branded-types.test.ts +47 -0
- package/src/__tests__/context.test.ts +163 -0
- package/src/__tests__/cost-tracker.test.ts +274 -0
- package/src/__tests__/cron.test.ts +197 -0
- package/src/__tests__/dream.test.ts +204 -0
- package/src/__tests__/error-handler.test.ts +192 -0
- package/src/__tests__/features.test.ts +69 -0
- package/src/__tests__/file-history.test.ts +177 -0
- package/src/__tests__/hooks.test.ts +145 -0
- package/src/__tests__/keybindings.test.ts +159 -0
- package/src/__tests__/model-patches.test.ts +82 -0
- package/src/__tests__/permissions-rules.test.ts +121 -0
- package/src/__tests__/permissions.test.ts +108 -0
- package/src/__tests__/project-config.test.ts +63 -0
- package/src/__tests__/retry.test.ts +321 -0
- package/src/__tests__/router.test.ts +158 -0
- package/src/__tests__/session-compact.test.ts +191 -0
- package/src/__tests__/session.test.ts +145 -0
- package/src/__tests__/skill-registry.test.ts +130 -0
- package/src/__tests__/speculation.test.ts +196 -0
- package/src/__tests__/tasks-v2.test.ts +267 -0
- package/src/__tests__/telemetry.test.ts +149 -0
- package/src/__tests__/tool-executor.test.ts +141 -0
- package/src/__tests__/tool-registry.test.ts +166 -0
- package/src/__tests__/undercover.test.ts +93 -0
- package/src/__tests__/workflow.test.ts +195 -0
- package/src/agent/async-context.ts +64 -0
- package/src/agent/context.ts +245 -0
- package/src/agent/cron.ts +189 -0
- package/src/agent/dream.ts +165 -0
- package/src/agent/error-handler.ts +108 -0
- package/src/agent/ipc.ts +256 -0
- package/src/agent/kairos.ts +207 -0
- package/src/agent/loop.ts +314 -0
- package/src/agent/model-patches.ts +68 -0
- package/src/agent/speculation.ts +219 -0
- package/src/agent/sub-agent.ts +125 -0
- package/src/agent/system-prompt.ts +231 -0
- package/src/agent/team.ts +220 -0
- package/src/agent/tool-executor.ts +162 -0
- package/src/agent/workflow.ts +189 -0
- package/src/agent/worktree-manager.ts +86 -0
- package/src/autopilot/queue.ts +186 -0
- package/src/autopilot/scanner.ts +245 -0
- package/src/autopilot/types.ts +58 -0
- package/src/bridge/bridge-client.ts +57 -0
- package/src/bridge/bridge-server.ts +81 -0
- package/src/cli.ts +1120 -0
- package/src/config/features.ts +51 -0
- package/src/config/git.ts +137 -0
- package/src/config/hooks.ts +201 -0
- package/src/config/permissions.ts +251 -0
- package/src/config/project-config.ts +63 -0
- package/src/config/remote-settings.ts +163 -0
- package/src/config/settings-sync.ts +170 -0
- package/src/config/settings.ts +113 -0
- package/src/config/undercover.ts +76 -0
- package/src/config/upgrade-notice.ts +65 -0
- package/src/mcp/client.ts +197 -0
- package/src/mcp/manager.ts +125 -0
- package/src/mcp/oauth.ts +252 -0
- package/src/mcp/types.ts +61 -0
- package/src/persistence/memory.ts +129 -0
- package/src/persistence/session.ts +289 -0
- package/src/planning/plan-mode.ts +128 -0
- package/src/planning/plan-tools.ts +138 -0
- package/src/providers/anthropic.ts +177 -0
- package/src/providers/cost-tracker.ts +184 -0
- package/src/providers/retry.ts +264 -0
- package/src/providers/router.ts +159 -0
- package/src/providers/types.ts +79 -0
- package/src/providers/xai.ts +217 -0
- package/src/repl.tsx +1384 -0
- package/src/setup.ts +119 -0
- package/src/skills/loader.ts +78 -0
- package/src/skills/registry.ts +78 -0
- package/src/skills/types.ts +11 -0
- package/src/state/file-history.ts +264 -0
- package/src/telemetry/event-log.ts +116 -0
- package/src/tools/agent.ts +133 -0
- package/src/tools/ask-user.ts +229 -0
- package/src/tools/bash.ts +146 -0
- package/src/tools/config.ts +147 -0
- package/src/tools/diff.ts +137 -0
- package/src/tools/file-edit.ts +123 -0
- package/src/tools/file-read.ts +82 -0
- package/src/tools/file-write.ts +82 -0
- package/src/tools/glob.ts +76 -0
- package/src/tools/grep.ts +187 -0
- package/src/tools/ls.ts +77 -0
- package/src/tools/lsp.ts +375 -0
- package/src/tools/mcp-resources.ts +83 -0
- package/src/tools/mcp-tool.ts +47 -0
- package/src/tools/memory.ts +148 -0
- package/src/tools/notebook-edit.ts +133 -0
- package/src/tools/peers.ts +113 -0
- package/src/tools/powershell.ts +83 -0
- package/src/tools/registry.ts +114 -0
- package/src/tools/send-message.ts +75 -0
- package/src/tools/sleep.ts +50 -0
- package/src/tools/snip.ts +143 -0
- package/src/tools/tasks.ts +349 -0
- package/src/tools/team.ts +309 -0
- package/src/tools/todo-write.ts +93 -0
- package/src/tools/tool-search.ts +83 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/web-browser.ts +263 -0
- package/src/tools/web-fetch.ts +118 -0
- package/src/tools/web-search.ts +107 -0
- package/src/tools/workflow.ts +188 -0
- package/src/tools/worktree.ts +143 -0
- package/src/types/branded.ts +22 -0
- package/src/ui/App.tsx +184 -0
- package/src/ui/BuddyPanel.tsx +52 -0
- package/src/ui/PermissionPrompt.tsx +29 -0
- package/src/ui/banner.ts +217 -0
- package/src/ui/buddy-ai.ts +108 -0
- package/src/ui/buddy.ts +466 -0
- package/src/ui/context-bar.ts +60 -0
- package/src/ui/effort.ts +65 -0
- package/src/ui/keybindings.ts +143 -0
- package/src/ui/markdown.ts +271 -0
- package/src/ui/message-renderer.ts +73 -0
- package/src/ui/mode.ts +80 -0
- package/src/ui/notifications.ts +57 -0
- package/src/ui/speech-bubble.ts +95 -0
- package/src/ui/spinner.ts +116 -0
- package/src/ui/theme.ts +98 -0
- package/src/version.ts +5 -0
- package/src/voice/voice-mode.ts +169 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Client — connects to an MCP server via stdio transport.
|
|
3
|
+
*
|
|
4
|
+
* Spawns a child process and communicates via JSON-RPC over stdin/stdout.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
MCPServerConfig,
|
|
9
|
+
MCPToolInfo,
|
|
10
|
+
MCPToolResult,
|
|
11
|
+
MCPInitializeResult,
|
|
12
|
+
JsonRpcRequest,
|
|
13
|
+
JsonRpcResponse,
|
|
14
|
+
} from "./types.ts";
|
|
15
|
+
|
|
16
|
+
export class MCPClient {
|
|
17
|
+
private proc: any = null;
|
|
18
|
+
private nextId = 1;
|
|
19
|
+
private pending = new Map<number, {
|
|
20
|
+
resolve: (value: unknown) => void;
|
|
21
|
+
reject: (error: Error) => void;
|
|
22
|
+
}>();
|
|
23
|
+
private rawBuffer = Buffer.alloc(0);
|
|
24
|
+
private serverInfo: MCPInitializeResult | null = null;
|
|
25
|
+
private _tools: MCPToolInfo[] = [];
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
readonly name: string,
|
|
29
|
+
private config: MCPServerConfig
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
get tools(): MCPToolInfo[] {
|
|
33
|
+
return this._tools;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get isConnected(): boolean {
|
|
37
|
+
return this.proc !== null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async connect(): Promise<void> {
|
|
41
|
+
const env = { ...process.env, ...this.config.env };
|
|
42
|
+
|
|
43
|
+
this.proc = Bun.spawn([this.config.command, ...(this.config.args ?? [])], {
|
|
44
|
+
stdin: "pipe",
|
|
45
|
+
stdout: "pipe",
|
|
46
|
+
stderr: "pipe",
|
|
47
|
+
env,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Read stdout in background
|
|
51
|
+
this.readLoop();
|
|
52
|
+
|
|
53
|
+
// Initialize
|
|
54
|
+
this.serverInfo = await this.request("initialize", {
|
|
55
|
+
protocolVersion: "2024-11-05",
|
|
56
|
+
capabilities: {},
|
|
57
|
+
clientInfo: { name: "ashlrcode", version: "0.7.0" },
|
|
58
|
+
}) as MCPInitializeResult;
|
|
59
|
+
|
|
60
|
+
// Send initialized notification
|
|
61
|
+
this.notify("notifications/initialized", {});
|
|
62
|
+
|
|
63
|
+
// List tools
|
|
64
|
+
const result = await this.request("tools/list", {}) as { tools: MCPToolInfo[] };
|
|
65
|
+
this._tools = result.tools ?? [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async callTool(name: string, args: Record<string, unknown>): Promise<MCPToolResult> {
|
|
69
|
+
const result = await this.request("tools/call", {
|
|
70
|
+
name,
|
|
71
|
+
arguments: args,
|
|
72
|
+
});
|
|
73
|
+
return result as MCPToolResult;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async disconnect(): Promise<void> {
|
|
77
|
+
if (this.proc) {
|
|
78
|
+
try {
|
|
79
|
+
this.proc.stdin.end();
|
|
80
|
+
this.proc.kill();
|
|
81
|
+
} catch {
|
|
82
|
+
// Process may already be dead
|
|
83
|
+
}
|
|
84
|
+
this.proc = null;
|
|
85
|
+
}
|
|
86
|
+
// Reject pending requests
|
|
87
|
+
for (const [, pending] of this.pending) {
|
|
88
|
+
pending.reject(new Error("MCP client disconnected"));
|
|
89
|
+
}
|
|
90
|
+
this.pending.clear();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async request(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
94
|
+
const id = this.nextId++;
|
|
95
|
+
const message: JsonRpcRequest = {
|
|
96
|
+
jsonrpc: "2.0",
|
|
97
|
+
id,
|
|
98
|
+
method,
|
|
99
|
+
params,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const timeout = setTimeout(() => {
|
|
104
|
+
this.pending.delete(id);
|
|
105
|
+
reject(new Error(`MCP request timeout: ${method}`));
|
|
106
|
+
}, 5_000); // 5s timeout (was 30s — don't block startup)
|
|
107
|
+
|
|
108
|
+
this.pending.set(id, {
|
|
109
|
+
resolve: (value) => { clearTimeout(timeout); resolve(value); },
|
|
110
|
+
reject: (err) => { clearTimeout(timeout); reject(err); },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
this.send(message as unknown as Record<string, unknown>);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private notify(method: string, params: Record<string, unknown>): void {
|
|
118
|
+
this.send({ jsonrpc: "2.0", method, params });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private send(message: Record<string, unknown>): void {
|
|
122
|
+
if (!this.proc) throw new Error("MCP client not connected");
|
|
123
|
+
const json = JSON.stringify(message);
|
|
124
|
+
// Use Buffer.byteLength for spec-correct Content-Length (UTF-8 bytes)
|
|
125
|
+
// Write as Buffer to ensure byte-level consistency with the header
|
|
126
|
+
const header = `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n`;
|
|
127
|
+
const buf = Buffer.concat([Buffer.from(header), Buffer.from(json, "utf-8")]);
|
|
128
|
+
this.proc.stdin.write(buf);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async readLoop(): Promise<void> {
|
|
132
|
+
if (!this.proc) return;
|
|
133
|
+
|
|
134
|
+
const reader = this.proc.stdout.getReader();
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
while (true) {
|
|
138
|
+
const { done, value } = await reader.read();
|
|
139
|
+
if (done) break;
|
|
140
|
+
this.rawBuffer = Buffer.concat([this.rawBuffer, Buffer.from(value)]);
|
|
141
|
+
this.processBuffer();
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// Stream ended or errored
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Reject all pending requests when read loop exits (server crash/EOF)
|
|
148
|
+
for (const [id, pending] of this.pending) {
|
|
149
|
+
this.pending.delete(id);
|
|
150
|
+
pending.reject(new Error("MCP server connection closed"));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private processBuffer(): void {
|
|
155
|
+
while (true) {
|
|
156
|
+
// Look for Content-Length header in byte buffer
|
|
157
|
+
const separator = Buffer.from("\r\n\r\n");
|
|
158
|
+
const headerEnd = this.rawBuffer.indexOf(separator);
|
|
159
|
+
if (headerEnd === -1) break;
|
|
160
|
+
|
|
161
|
+
const header = this.rawBuffer.subarray(0, headerEnd).toString("utf-8");
|
|
162
|
+
const match = header.match(/Content-Length:\s*(\d+)/i);
|
|
163
|
+
if (!match) {
|
|
164
|
+
// Skip malformed header
|
|
165
|
+
this.rawBuffer = this.rawBuffer.subarray(headerEnd + 4);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const contentLength = parseInt(match[1]!, 10);
|
|
170
|
+
const bodyStart = headerEnd + 4;
|
|
171
|
+
const bodyEnd = bodyStart + contentLength;
|
|
172
|
+
|
|
173
|
+
if (this.rawBuffer.length < bodyEnd) break; // Need more data
|
|
174
|
+
|
|
175
|
+
const body = this.rawBuffer.subarray(bodyStart, bodyEnd).toString("utf-8");
|
|
176
|
+
this.rawBuffer = this.rawBuffer.subarray(bodyEnd);
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const message = JSON.parse(body) as JsonRpcResponse;
|
|
180
|
+
if ("id" in message && message.id !== undefined) {
|
|
181
|
+
const pending = this.pending.get(message.id);
|
|
182
|
+
if (pending) {
|
|
183
|
+
this.pending.delete(message.id);
|
|
184
|
+
if (message.error) {
|
|
185
|
+
pending.reject(new Error(`MCP error: ${message.error.message}`));
|
|
186
|
+
} else {
|
|
187
|
+
pending.resolve(message.result);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Notifications (no id) are ignored for now
|
|
192
|
+
} catch {
|
|
193
|
+
// Malformed JSON, skip
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Connection Manager — manages multiple MCP server connections.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import { MCPClient } from "./client.ts";
|
|
7
|
+
import type { MCPServerConfig, MCPToolInfo } from "./types.ts";
|
|
8
|
+
import { authorizeOAuth } from "./oauth.ts";
|
|
9
|
+
import type { OAuthConfig } from "./oauth.ts";
|
|
10
|
+
|
|
11
|
+
export class MCPManager {
|
|
12
|
+
private clients = new Map<string, MCPClient>();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Connect to all configured MCP servers.
|
|
16
|
+
*/
|
|
17
|
+
async connectAll(
|
|
18
|
+
servers: Record<string, MCPServerConfig>
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const entries = Object.entries(servers);
|
|
21
|
+
if (entries.length === 0) return;
|
|
22
|
+
|
|
23
|
+
const results = await Promise.allSettled(
|
|
24
|
+
entries.map(async ([name, config]) => {
|
|
25
|
+
// If OAuth is configured, authenticate before connecting
|
|
26
|
+
let effectiveConfig = config;
|
|
27
|
+
if (config.oauth) {
|
|
28
|
+
try {
|
|
29
|
+
const oauthConfig: OAuthConfig = {
|
|
30
|
+
...config.oauth,
|
|
31
|
+
scopes: config.oauth.scopes ?? [],
|
|
32
|
+
};
|
|
33
|
+
const token = await authorizeOAuth(name, oauthConfig);
|
|
34
|
+
// Pass the Bearer token to the MCP server via environment
|
|
35
|
+
effectiveConfig = {
|
|
36
|
+
...config,
|
|
37
|
+
env: {
|
|
38
|
+
...config.env,
|
|
39
|
+
MCP_AUTH_TOKEN: token.accessToken,
|
|
40
|
+
MCP_AUTH_TYPE: token.tokenType,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.log(
|
|
45
|
+
chalk.dim(
|
|
46
|
+
` MCP: ${name} OAuth failed — ${err instanceof Error ? err.message : "unknown error"}`
|
|
47
|
+
)
|
|
48
|
+
);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const client = new MCPClient(name, effectiveConfig);
|
|
54
|
+
try {
|
|
55
|
+
await client.connect();
|
|
56
|
+
this.clients.set(name, client);
|
|
57
|
+
console.log(
|
|
58
|
+
chalk.dim(
|
|
59
|
+
` MCP: ${name} connected (${client.tools.length} tools)`
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
} catch {
|
|
63
|
+
// Silently skip failed MCP servers — they may not be running
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get all discovered tools across all connected servers.
|
|
71
|
+
*/
|
|
72
|
+
getAllTools(): Array<{ serverName: string; tool: MCPToolInfo }> {
|
|
73
|
+
const tools: Array<{ serverName: string; tool: MCPToolInfo }> = [];
|
|
74
|
+
for (const [name, client] of this.clients) {
|
|
75
|
+
for (const tool of client.tools) {
|
|
76
|
+
tools.push({ serverName: name, tool });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return tools;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Call a tool on a specific server.
|
|
84
|
+
*/
|
|
85
|
+
async callTool(
|
|
86
|
+
serverName: string,
|
|
87
|
+
toolName: string,
|
|
88
|
+
args: Record<string, unknown>
|
|
89
|
+
): Promise<string> {
|
|
90
|
+
const client = this.clients.get(serverName);
|
|
91
|
+
if (!client) {
|
|
92
|
+
return `MCP server "${serverName}" not connected`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const result = await client.callTool(toolName, args);
|
|
96
|
+
|
|
97
|
+
// Extract text from result content blocks
|
|
98
|
+
const text = result.content
|
|
99
|
+
.map((c) => c.text ?? JSON.stringify(c))
|
|
100
|
+
.join("\n");
|
|
101
|
+
|
|
102
|
+
if (result.isError) {
|
|
103
|
+
return `MCP Error: ${text}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return text;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Disconnect all servers.
|
|
111
|
+
*/
|
|
112
|
+
async disconnectAll(): Promise<void> {
|
|
113
|
+
for (const [, client] of this.clients) {
|
|
114
|
+
await client.disconnect();
|
|
115
|
+
}
|
|
116
|
+
this.clients.clear();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get connected server names.
|
|
121
|
+
*/
|
|
122
|
+
getServerNames(): string[] {
|
|
123
|
+
return Array.from(this.clients.keys());
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/mcp/oauth.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP OAuth 2.0 — authentication flow for MCP servers.
|
|
3
|
+
* Supports authorization code flow with PKCE, token refresh, and API key fallback.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createServer } from "http";
|
|
7
|
+
import { randomBytes, createHash } from "crypto";
|
|
8
|
+
import { readFile, writeFile, mkdir, unlink } from "fs/promises";
|
|
9
|
+
import { existsSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { getConfigDir } from "../config/settings.ts";
|
|
12
|
+
|
|
13
|
+
export interface OAuthConfig {
|
|
14
|
+
authorizationUrl: string;
|
|
15
|
+
tokenUrl: string;
|
|
16
|
+
clientId: string;
|
|
17
|
+
clientSecret?: string;
|
|
18
|
+
scopes: string[];
|
|
19
|
+
redirectPort?: number; // Default: 8742
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TokenSet {
|
|
23
|
+
accessToken: string;
|
|
24
|
+
refreshToken?: string;
|
|
25
|
+
expiresAt: number; // Unix timestamp ms
|
|
26
|
+
tokenType: string;
|
|
27
|
+
scope?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getTokenCachePath(serverId: string): string {
|
|
31
|
+
return join(getConfigDir(), "mcp-tokens", `${serverId}.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Load cached tokens for an MCP server */
|
|
35
|
+
export async function loadCachedToken(
|
|
36
|
+
serverId: string
|
|
37
|
+
): Promise<TokenSet | null> {
|
|
38
|
+
const path = getTokenCachePath(serverId);
|
|
39
|
+
if (!existsSync(path)) return null;
|
|
40
|
+
try {
|
|
41
|
+
const raw = await readFile(path, "utf-8");
|
|
42
|
+
const token = JSON.parse(raw) as TokenSet;
|
|
43
|
+
// If expired and no refresh token, discard
|
|
44
|
+
if (token.expiresAt && Date.now() > token.expiresAt - 60_000) {
|
|
45
|
+
if (token.refreshToken) return token; // Can still refresh
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return token;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Save tokens to cache */
|
|
55
|
+
async function saveToken(serverId: string, token: TokenSet): Promise<void> {
|
|
56
|
+
const dir = join(getConfigDir(), "mcp-tokens");
|
|
57
|
+
await mkdir(dir, { recursive: true });
|
|
58
|
+
await writeFile(
|
|
59
|
+
getTokenCachePath(serverId),
|
|
60
|
+
JSON.stringify(token, null, 2),
|
|
61
|
+
{ encoding: "utf-8", mode: 0o600 }
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Generate PKCE code verifier and challenge (S256) */
|
|
66
|
+
function generatePKCE(): { verifier: string; challenge: string } {
|
|
67
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
68
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
69
|
+
return { verifier, challenge };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Refresh an expired token */
|
|
73
|
+
async function refreshAccessToken(
|
|
74
|
+
config: OAuthConfig,
|
|
75
|
+
refreshTokenStr: string
|
|
76
|
+
): Promise<TokenSet> {
|
|
77
|
+
const response = await fetch(config.tokenUrl, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
80
|
+
body: new URLSearchParams({
|
|
81
|
+
grant_type: "refresh_token",
|
|
82
|
+
refresh_token: refreshTokenStr,
|
|
83
|
+
client_id: config.clientId,
|
|
84
|
+
...(config.clientSecret
|
|
85
|
+
? { client_secret: config.clientSecret }
|
|
86
|
+
: {}),
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
throw new Error(`Token refresh failed: ${response.status}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
95
|
+
return {
|
|
96
|
+
accessToken: data.access_token as string,
|
|
97
|
+
refreshToken:
|
|
98
|
+
(data.refresh_token as string | undefined) ?? refreshTokenStr,
|
|
99
|
+
expiresAt:
|
|
100
|
+
Date.now() + ((data.expires_in as number | undefined) ?? 3600) * 1000,
|
|
101
|
+
tokenType: (data.token_type as string | undefined) ?? "Bearer",
|
|
102
|
+
scope: data.scope as string | undefined,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run OAuth 2.0 authorization code flow with PKCE.
|
|
108
|
+
* Opens browser for auth, listens on localhost for redirect.
|
|
109
|
+
* Returns a valid TokenSet (from cache, refresh, or fresh authorization).
|
|
110
|
+
*/
|
|
111
|
+
export async function authorizeOAuth(
|
|
112
|
+
serverId: string,
|
|
113
|
+
config: OAuthConfig
|
|
114
|
+
): Promise<TokenSet> {
|
|
115
|
+
// 1. Check cache — return immediately if still valid
|
|
116
|
+
const cached = await loadCachedToken(serverId);
|
|
117
|
+
if (cached && Date.now() < cached.expiresAt - 60_000) return cached;
|
|
118
|
+
|
|
119
|
+
// 2. Try refresh if we have a refresh token
|
|
120
|
+
if (cached?.refreshToken) {
|
|
121
|
+
try {
|
|
122
|
+
const refreshed = await refreshAccessToken(config, cached.refreshToken);
|
|
123
|
+
await saveToken(serverId, refreshed);
|
|
124
|
+
return refreshed;
|
|
125
|
+
} catch {
|
|
126
|
+
// Refresh failed — fall through to full authorization
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 3. Full authorization code flow with PKCE
|
|
131
|
+
const port = config.redirectPort ?? 8742;
|
|
132
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
133
|
+
const { verifier, challenge } = generatePKCE();
|
|
134
|
+
const state = randomBytes(16).toString("hex");
|
|
135
|
+
|
|
136
|
+
const params = new URLSearchParams({
|
|
137
|
+
response_type: "code",
|
|
138
|
+
client_id: config.clientId,
|
|
139
|
+
redirect_uri: redirectUri,
|
|
140
|
+
scope: config.scopes.join(" "),
|
|
141
|
+
state,
|
|
142
|
+
code_challenge: challenge,
|
|
143
|
+
code_challenge_method: "S256",
|
|
144
|
+
});
|
|
145
|
+
const authUrl = `${config.authorizationUrl}?${params}`;
|
|
146
|
+
|
|
147
|
+
// Open browser (platform-aware)
|
|
148
|
+
const openCmd =
|
|
149
|
+
process.platform === "darwin"
|
|
150
|
+
? "open"
|
|
151
|
+
: process.platform === "win32"
|
|
152
|
+
? "start"
|
|
153
|
+
: "xdg-open";
|
|
154
|
+
Bun.spawn([openCmd, authUrl], { stdout: "pipe", stderr: "pipe" });
|
|
155
|
+
|
|
156
|
+
console.log(`\n 🔐 Opening browser for authorization...`);
|
|
157
|
+
console.log(` If browser doesn't open, visit: ${authUrl}\n`);
|
|
158
|
+
|
|
159
|
+
// Listen for the OAuth callback
|
|
160
|
+
const code = await new Promise<string>((resolve, reject) => {
|
|
161
|
+
const timeout = setTimeout(() => {
|
|
162
|
+
server.close();
|
|
163
|
+
reject(new Error("OAuth timeout — no callback received within 120s"));
|
|
164
|
+
}, 120_000);
|
|
165
|
+
|
|
166
|
+
const server = createServer((req, res) => {
|
|
167
|
+
const url = new URL(req.url!, `http://localhost:${port}`);
|
|
168
|
+
|
|
169
|
+
if (url.pathname !== "/callback") {
|
|
170
|
+
res.writeHead(404);
|
|
171
|
+
res.end();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const returnedState = url.searchParams.get("state");
|
|
176
|
+
const returnedCode = url.searchParams.get("code");
|
|
177
|
+
const error = url.searchParams.get("error");
|
|
178
|
+
|
|
179
|
+
if (error) {
|
|
180
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
181
|
+
res.end(
|
|
182
|
+
"<h1>Authorization Failed</h1><p>You can close this window.</p>"
|
|
183
|
+
);
|
|
184
|
+
clearTimeout(timeout);
|
|
185
|
+
server.close();
|
|
186
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (returnedState !== state || !returnedCode) {
|
|
191
|
+
res.writeHead(400);
|
|
192
|
+
res.end("Invalid state or missing code");
|
|
193
|
+
clearTimeout(timeout);
|
|
194
|
+
server.close();
|
|
195
|
+
reject(new Error("Invalid OAuth state or missing code"));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
200
|
+
res.end(
|
|
201
|
+
"<h1>Authorization Complete</h1><p>You can close this window and return to AshlrCode.</p>"
|
|
202
|
+
);
|
|
203
|
+
clearTimeout(timeout);
|
|
204
|
+
server.close();
|
|
205
|
+
resolve(returnedCode);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
server.listen(port);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// 4. Exchange authorization code for tokens
|
|
212
|
+
const tokenResponse = await fetch(config.tokenUrl, {
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
215
|
+
body: new URLSearchParams({
|
|
216
|
+
grant_type: "authorization_code",
|
|
217
|
+
code,
|
|
218
|
+
redirect_uri: redirectUri,
|
|
219
|
+
client_id: config.clientId,
|
|
220
|
+
...(config.clientSecret
|
|
221
|
+
? { client_secret: config.clientSecret }
|
|
222
|
+
: {}),
|
|
223
|
+
code_verifier: verifier,
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (!tokenResponse.ok) {
|
|
228
|
+
throw new Error(`Token exchange failed: ${tokenResponse.status}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const data = (await tokenResponse.json()) as Record<string, unknown>;
|
|
232
|
+
const token: TokenSet = {
|
|
233
|
+
accessToken: data.access_token as string,
|
|
234
|
+
refreshToken: data.refresh_token as string | undefined,
|
|
235
|
+
expiresAt:
|
|
236
|
+
Date.now() + ((data.expires_in as number | undefined) ?? 3600) * 1000,
|
|
237
|
+
tokenType: (data.token_type as string | undefined) ?? "Bearer",
|
|
238
|
+
scope: data.scope as string | undefined,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
await saveToken(serverId, token);
|
|
242
|
+
console.log(" ✓ Authorization successful\n");
|
|
243
|
+
return token;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Revoke / delete cached token for a server */
|
|
247
|
+
export async function revokeToken(serverId: string): Promise<void> {
|
|
248
|
+
const path = getTokenCachePath(serverId);
|
|
249
|
+
if (existsSync(path)) {
|
|
250
|
+
await unlink(path);
|
|
251
|
+
}
|
|
252
|
+
}
|
package/src/mcp/types.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP (Model Context Protocol) types — JSON-RPC over stdio.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface MCPServerConfig {
|
|
6
|
+
command: string;
|
|
7
|
+
args?: string[];
|
|
8
|
+
env?: Record<string, string>;
|
|
9
|
+
oauth?: {
|
|
10
|
+
authorizationUrl: string;
|
|
11
|
+
tokenUrl: string;
|
|
12
|
+
clientId: string;
|
|
13
|
+
clientSecret?: string;
|
|
14
|
+
scopes: string[];
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// JSON-RPC 2.0 message types
|
|
19
|
+
export interface JsonRpcRequest {
|
|
20
|
+
jsonrpc: "2.0";
|
|
21
|
+
id: number;
|
|
22
|
+
method: string;
|
|
23
|
+
params?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface JsonRpcResponse {
|
|
27
|
+
jsonrpc: "2.0";
|
|
28
|
+
id: number;
|
|
29
|
+
result?: unknown;
|
|
30
|
+
error?: { code: number; message: string; data?: unknown };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface JsonRpcNotification {
|
|
34
|
+
jsonrpc: "2.0";
|
|
35
|
+
method: string;
|
|
36
|
+
params?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// MCP-specific types
|
|
40
|
+
export interface MCPToolInfo {
|
|
41
|
+
name: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
inputSchema: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MCPToolResult {
|
|
47
|
+
content: Array<{ type: string; text?: string }>;
|
|
48
|
+
isError?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MCPServerCapabilities {
|
|
52
|
+
tools?: Record<string, unknown>;
|
|
53
|
+
resources?: Record<string, unknown>;
|
|
54
|
+
prompts?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface MCPInitializeResult {
|
|
58
|
+
protocolVersion: string;
|
|
59
|
+
capabilities: MCPServerCapabilities;
|
|
60
|
+
serverInfo: { name: string; version?: string };
|
|
61
|
+
}
|