@wolpertingerlabs/drawlatch 1.0.0-alpha.4 → 1.0.0-alpha.40
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/CONNECTIONS.md +9 -1
- package/README.md +393 -465
- package/bin/drawlatch.js +1149 -47
- package/dist/auth/auth.d.ts +10 -0
- package/dist/auth/auth.js +146 -0
- package/dist/auth/env-writer.d.ts +6 -0
- package/dist/auth/env-writer.js +52 -0
- package/dist/auth/password.d.ts +8 -0
- package/dist/auth/password.js +28 -0
- package/dist/auth/sessions.d.ts +13 -0
- package/dist/auth/sessions.js +85 -0
- package/dist/cli/generate-keys.d.ts +4 -4
- package/dist/cli/generate-keys.js +30 -25
- package/dist/connections/{anthropic.json → ai/anthropic.json} +2 -0
- package/dist/connections/{devin.json → ai/devin.json} +2 -0
- package/dist/connections/ai/exa.json +24 -0
- package/dist/connections/{google-ai.json → ai/google-ai.json} +2 -0
- package/dist/connections/{openai.json → ai/openai.json} +2 -0
- package/dist/connections/{openrouter.json → ai/openrouter.json} +2 -0
- package/dist/connections/developer-tools/datadog.json +27 -0
- package/dist/connections/{github.json → developer-tools/github.json} +59 -3
- package/dist/connections/{hex.json → developer-tools/hex.json} +2 -0
- package/dist/connections/{linear.json → developer-tools/linear.json} +2 -0
- package/dist/connections/{lichess.json → gaming/lichess.json} +2 -0
- package/dist/connections/messaging/agentmail.json +21 -0
- package/dist/connections/{discord-bot.json → messaging/discord-bot.json} +2 -0
- package/dist/connections/{discord-oauth.json → messaging/discord-oauth.json} +2 -0
- package/dist/connections/{slack.json → messaging/slack.json} +2 -0
- package/dist/connections/{telegram.json → messaging/telegram.json} +2 -0
- package/dist/connections/{google.json → productivity/google.json} +2 -0
- package/dist/connections/{notion.json → productivity/notion.json} +2 -0
- package/dist/connections/{stripe.json → productivity/stripe.json} +2 -0
- package/dist/connections/{trello.json → productivity/trello.json} +2 -0
- package/dist/connections/{bluesky.json → social-media/bluesky.json} +2 -0
- package/dist/connections/{mastodon.json → social-media/mastodon.json} +2 -0
- package/dist/connections/{reddit.json → social-media/reddit.json} +2 -0
- package/dist/connections/{twitch.json → social-media/twitch.json} +2 -0
- package/dist/connections/{x.json → social-media/x.json} +2 -0
- package/dist/mcp/server.js +268 -33
- package/dist/remote/admin-mutations.d.ts +43 -0
- package/dist/remote/admin-mutations.js +321 -0
- package/dist/remote/admin-types.d.ts +153 -0
- package/dist/remote/admin-types.js +11 -0
- package/dist/remote/admin.d.ts +37 -0
- package/dist/remote/admin.js +317 -0
- package/dist/remote/caller-bootstrap.d.ts +121 -0
- package/dist/remote/caller-bootstrap.js +270 -0
- package/dist/remote/ingestors/base-ingestor.d.ts +5 -0
- package/dist/remote/ingestors/base-ingestor.js +17 -4
- package/dist/remote/ingestors/discord/discord-gateway.js +24 -1
- package/dist/remote/ingestors/e2e/setup.d.ts +69 -0
- package/dist/remote/ingestors/e2e/setup.js +147 -0
- package/dist/remote/ingestors/manager.d.ts +35 -1
- package/dist/remote/ingestors/manager.js +160 -22
- package/dist/remote/ingestors/poll/poll-ingestor.d.ts +2 -0
- package/dist/remote/ingestors/poll/poll-ingestor.js +21 -0
- package/dist/remote/ingestors/slack/socket-mode.js +23 -1
- package/dist/remote/ingestors/types.d.ts +8 -0
- package/dist/remote/ingestors/webhook/github-webhook-ingestor.d.ts +16 -0
- package/dist/remote/ingestors/webhook/github-webhook-ingestor.js +49 -3
- package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.js +9 -9
- package/dist/remote/server.d.ts +25 -33
- package/dist/remote/server.js +595 -735
- package/dist/remote/tool-dispatch.d.ts +84 -0
- package/dist/remote/tool-dispatch.js +910 -0
- package/dist/remote/triggers/rule-engine.d.ts +40 -0
- package/dist/remote/triggers/rule-engine.js +228 -0
- package/dist/remote/triggers/types.d.ts +69 -0
- package/dist/remote/triggers/types.js +10 -0
- package/dist/remote/tunnel-state.d.ts +14 -0
- package/dist/remote/tunnel-state.js +20 -0
- package/dist/remote/tunnel.d.ts +13 -0
- package/dist/remote/tunnel.js +33 -0
- package/dist/shared/config.d.ts +61 -27
- package/dist/shared/config.js +41 -33
- package/dist/shared/connections.d.ts +12 -5
- package/dist/shared/connections.js +52 -14
- package/dist/shared/crypto/index.d.ts +1 -0
- package/dist/shared/crypto/index.js +1 -0
- package/dist/shared/crypto/key-manager.d.ts +81 -0
- package/dist/shared/crypto/key-manager.js +174 -0
- package/dist/shared/env-utils.d.ts +42 -0
- package/dist/shared/env-utils.js +150 -0
- package/dist/shared/migrations.d.ts +40 -0
- package/dist/shared/migrations.js +122 -0
- package/dist/shared/protocol/caller-bundle-crypto.d.ts +37 -0
- package/dist/shared/protocol/caller-bundle-crypto.js +62 -0
- package/dist/shared/protocol/caller-bundle.d.ts +75 -0
- package/dist/shared/protocol/caller-bundle.js +24 -0
- package/dist/shared/protocol/handshake.js +14 -1
- package/dist/shared/protocol/index.d.ts +2 -0
- package/dist/shared/protocol/index.js +2 -0
- package/dist/shared/protocol/sync-client.d.ts +52 -0
- package/dist/shared/protocol/sync-client.js +99 -0
- package/dist/shared/protocol/sync.d.ts +71 -0
- package/dist/shared/protocol/sync.js +176 -0
- package/frontend/dist/assets/index-BdCSPSZK.js +282 -0
- package/frontend/dist/assets/index-BmK26bY2.css +1 -0
- package/frontend/dist/index.html +15 -0
- package/package.json +63 -11
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Hex API",
|
|
3
|
+
"stability": "dev",
|
|
4
|
+
"category": "developer-tools",
|
|
3
5
|
"description": "Hex API — projects, runs, users, groups, collections, and data connections. Auth is handled automatically via the HEX_TOKEN environment variable.",
|
|
4
6
|
"docsUrl": "https://learn.hex.tech/docs/api/api-overview",
|
|
5
7
|
"headers": {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Linear API",
|
|
3
|
+
"stability": "beta",
|
|
4
|
+
"category": "developer-tools",
|
|
3
5
|
"description": "Linear GraphQL API — issues, projects, teams, cycles, and more. Auth is handled automatically via the LINEAR_API_KEY environment variable. All requests are GraphQL queries sent as POST to https://api.linear.app/graphql. Includes a poll ingestor that periodically fetches recently updated issues — use poll_events to retrieve them.",
|
|
4
6
|
"docsUrl": "https://developers.linear.app/docs/graphql/working-with-the-graphql-api",
|
|
5
7
|
"headers": {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Lichess API",
|
|
3
|
+
"stability": "stable",
|
|
4
|
+
"category": "gaming",
|
|
3
5
|
"description": "Lichess Opening Explorer, Cloud Evaluation, and authenticated API — query master and rated game statistics by opening move sequence, get cached Stockfish engine evaluations by FEN position, and access authenticated endpoints (account info, game history, player-specific opening analysis, etc.). Set LICHESS_API_TOKEN to a personal access token for authenticated routes; public endpoints (Opening Explorer, Cloud Eval) work without it.",
|
|
4
6
|
"docsUrl": "https://lichess.org/api",
|
|
5
7
|
"headers": {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "AgentMail API",
|
|
3
|
+
"stability": "beta",
|
|
4
|
+
"category": "messaging",
|
|
5
|
+
"description": "AgentMail API — email infrastructure for AI agents. Create and manage inboxes, send and reply to messages, browse threads, and manage drafts and attachments. Auth is handled automatically via the AGENTMAIL_API_KEY environment variable using a Bearer token. All endpoints are under the /v0 path. Create an API key in the AgentMail Console (https://console.agentmail.to).",
|
|
6
|
+
"docsUrl": "https://docs.agentmail.to/api-reference",
|
|
7
|
+
"headers": {
|
|
8
|
+
"Authorization": "Bearer ${AGENTMAIL_API_KEY}",
|
|
9
|
+
"Content-Type": "application/json"
|
|
10
|
+
},
|
|
11
|
+
"secrets": {
|
|
12
|
+
"AGENTMAIL_API_KEY": "${AGENTMAIL_API_KEY}"
|
|
13
|
+
},
|
|
14
|
+
"allowedEndpoints": [
|
|
15
|
+
"https://api.agentmail.to/**"
|
|
16
|
+
],
|
|
17
|
+
"testConnection": {
|
|
18
|
+
"url": "https://api.agentmail.to/v0/inboxes",
|
|
19
|
+
"description": "Lists inboxes to verify the API key"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Discord Bot API",
|
|
3
|
+
"stability": "stable",
|
|
4
|
+
"category": "messaging",
|
|
3
5
|
"description": "Discord Bot API (v10) — guilds, channels, messages, users, roles, and more. Auth is handled automatically via the DISCORD_BOT_TOKEN environment variable. Uses the 'Bot' authorization prefix. For OAuth2 user-scoped access, use the 'discord-oauth' connection instead. Includes a Gateway ingestor for real-time events (messages, reactions, etc.) — use poll_events to retrieve them.",
|
|
4
6
|
"docsUrl": "https://discord.com/developers/docs/intro",
|
|
5
7
|
"openApiUrl": "https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Discord OAuth2 API",
|
|
3
|
+
"stability": "dev",
|
|
4
|
+
"category": "messaging",
|
|
3
5
|
"description": "Discord OAuth2 API (v10) — user identity, guilds, connections, and other user-scoped data. Auth is handled automatically via the DISCORD_OAUTH_TOKEN environment variable (OAuth2 Bearer access token). Access is limited to the scopes the user authorized. For full bot access, use the 'discord-bot' connection instead.",
|
|
4
6
|
"docsUrl": "https://discord.com/developers/docs/topics/oauth2",
|
|
5
7
|
"openApiUrl": "https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Slack API",
|
|
3
|
+
"stability": "stable",
|
|
4
|
+
"category": "messaging",
|
|
3
5
|
"description": "Slack Web API — messages, channels, users, reactions, files, and more. Auth is handled automatically via the SLACK_BOT_TOKEN environment variable. Real-time events via Socket Mode require SLACK_APP_TOKEN.",
|
|
4
6
|
"docsUrl": "https://docs.slack.dev/apis/web-api",
|
|
5
7
|
"openApiUrl": "https://raw.githubusercontent.com/slackapi/slack-api-specs/master/web-api/slack_web_openapi_v2.json",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Telegram Bot API",
|
|
3
|
+
"stability": "beta",
|
|
4
|
+
"category": "messaging",
|
|
3
5
|
"description": "Telegram Bot API — messages, chats, users, inline queries, payments, and more. Auth uses a bot token embedded in the URL path — include /bot${TELEGRAM_BOT_TOKEN}/ in your request URLs. The placeholder is resolved automatically from the route's secrets. Create a bot via @BotFather on Telegram to obtain a token. Includes a poll ingestor that fetches new updates via getUpdates — use poll_events to retrieve them.",
|
|
4
6
|
"docsUrl": "https://core.telegram.org/bots/api",
|
|
5
7
|
"headers": {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Google APIs",
|
|
3
|
+
"stability": "dev",
|
|
4
|
+
"category": "productivity",
|
|
3
5
|
"description": "Google APIs — Sheets, Docs, Drive, Calendar, Gmail, and more. Auth is handled automatically via the GOOGLE_API_TOKEN environment variable (OAuth2 access token or service account token). Multiple Google API domains are allowlisted.",
|
|
4
6
|
"docsUrl": "https://developers.google.com/apis-explorer",
|
|
5
7
|
"headers": {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Notion API",
|
|
3
|
+
"stability": "beta",
|
|
4
|
+
"category": "productivity",
|
|
3
5
|
"description": "Notion API — pages, databases, blocks, users, search, and more. Auth is handled automatically via the NOTION_API_KEY environment variable (internal integration token). The Notion-Version header is set to 2022-06-28. Includes a poll ingestor that periodically searches for recently edited pages — use poll_events to retrieve them.",
|
|
4
6
|
"docsUrl": "https://developers.notion.com/reference",
|
|
5
7
|
"headers": {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Stripe API",
|
|
3
|
+
"stability": "beta",
|
|
4
|
+
"category": "productivity",
|
|
3
5
|
"description": "Stripe payments API — customers, charges, payment intents, subscriptions, invoices, and more. Auth is handled automatically via the STRIPE_SECRET_KEY environment variable. Includes a webhook ingestor for real-time events (payment_intent.succeeded, invoice.paid, etc.) — set STRIPE_WEBHOOK_SECRET and use poll_events to retrieve them. Note: Stripe uses application/x-www-form-urlencoded for request bodies, not JSON.",
|
|
4
6
|
"docsUrl": "https://docs.stripe.com/api",
|
|
5
7
|
"openApiUrl": "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Trello API",
|
|
3
|
+
"stability": "stable",
|
|
4
|
+
"category": "productivity",
|
|
3
5
|
"description": "Trello boards, lists, and cards API. Authentication uses query parameters — include ?key=${TRELLO_API_KEY}&token=${TRELLO_TOKEN} in your request URLs. The placeholders are resolved automatically from the route's secrets. Includes a webhook ingestor for real-time events (card updates, list changes, board activity, etc.) — set TRELLO_API_SECRET and TRELLO_CALLBACK_URL, then use poll_events to retrieve them.",
|
|
4
6
|
"docsUrl": "https://developer.atlassian.com/cloud/trello/rest/",
|
|
5
7
|
"headers": {},
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Bluesky API (AT Protocol)",
|
|
3
|
+
"stability": "beta",
|
|
4
|
+
"category": "social-media",
|
|
3
5
|
"description": "Bluesky API via the AT Protocol — posts, feeds, profiles, notifications, social graph, and more. Auth is handled automatically via the BLUESKY_ACCESS_TOKEN environment variable (JWT Bearer token). Obtain a token by POSTing to com.atproto.server.createSession with your handle and an App Password. Tokens expire after ~2 hours — rotate externally using the refresh token. Both bsky.social (authenticated PDS) and public.api.bsky.app (public read-only API) are allowlisted. For self-hosted PDS instances, override with a custom connector. Includes a poll ingestor that fetches notifications — use poll_events to retrieve them.",
|
|
4
6
|
"docsUrl": "https://docs.bsky.app/",
|
|
5
7
|
"headers": {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Mastodon API",
|
|
3
|
+
"stability": "beta",
|
|
4
|
+
"category": "social-media",
|
|
3
5
|
"description": "Mastodon API — statuses (toots), timelines, notifications, accounts, search, and more. Auth is handled automatically via the MASTODON_ACCESS_TOKEN environment variable (OAuth2 Bearer token from your instance's Development settings). This template targets mastodon.social — for other instances, override with a custom connector using the same auth pattern but different allowedEndpoints. Includes a poll ingestor that fetches the home timeline — use poll_events to retrieve them.",
|
|
4
6
|
"docsUrl": "https://docs.joinmastodon.org/api/",
|
|
5
7
|
"headers": {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Reddit API",
|
|
3
|
+
"stability": "beta",
|
|
4
|
+
"category": "social-media",
|
|
3
5
|
"description": "Reddit API — subreddits, posts, comments, users, search, and more. Auth is handled automatically via the REDDIT_ACCESS_TOKEN environment variable (OAuth2 Bearer token). A User-Agent header is required by Reddit and set via REDDIT_USER_AGENT. Obtain an OAuth2 token by registering a 'script' app at https://www.reddit.com/prefs/apps and using the client credentials grant. Tokens expire after 1 hour — rotate externally. Includes a poll ingestor that fetches new posts from a configurable subreddit — set REDDIT_SUBREDDIT and use poll_events to retrieve them.",
|
|
4
6
|
"docsUrl": "https://www.reddit.com/dev/api/",
|
|
5
7
|
"headers": {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Twitch API",
|
|
3
|
+
"stability": "beta",
|
|
4
|
+
"category": "social-media",
|
|
3
5
|
"description": "Twitch Helix API — streams, users, channels, clips, videos, chat, and more. Auth requires both a Bearer token and Client-Id header, handled automatically via TWITCH_ACCESS_TOKEN and TWITCH_CLIENT_ID environment variables. Register an application at https://dev.twitch.tv/console/apps to obtain credentials. Includes a poll ingestor that fetches followed live streams — set TWITCH_USER_ID and use poll_events to retrieve them.",
|
|
4
6
|
"docsUrl": "https://dev.twitch.tv/docs/api/reference/",
|
|
5
7
|
"headers": {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "X (Twitter) API",
|
|
3
|
+
"stability": "beta",
|
|
4
|
+
"category": "social-media",
|
|
3
5
|
"description": "X (formerly Twitter) API v2 — tweets, users, spaces, lists, and more. Auth is handled automatically via the X_BEARER_TOKEN environment variable (App-only Bearer token from the X Developer Portal). Both api.x.com and api.twitter.com domains are allowlisted. Includes a poll ingestor that searches recent tweets matching a configurable query — set X_SEARCH_QUERY and use poll_events to retrieve them.",
|
|
4
6
|
"docsUrl": "https://developer.x.com/en/docs/x-api",
|
|
5
7
|
"headers": {
|
package/dist/mcp/server.js
CHANGED
|
@@ -13,38 +13,81 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
13
13
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
14
|
import { z } from 'zod';
|
|
15
15
|
import crypto from 'node:crypto';
|
|
16
|
-
import
|
|
17
|
-
import
|
|
16
|
+
import fs from 'node:fs/promises';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { loadProxyConfig, resolveCallerKeyAlias, getCallerKeysDir, getServerKeysDir, getProxyConfigPath, } from '../shared/config.js';
|
|
19
|
+
import { loadKeyBundle, loadPublicKeys, EncryptedChannel, extractPublicKeys, fingerprint, } from '../shared/crypto/index.js';
|
|
20
|
+
import { existsSync } from 'node:fs';
|
|
18
21
|
import { HandshakeInitiator, } from '../shared/protocol/index.js';
|
|
19
22
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
20
23
|
let channel = null;
|
|
21
24
|
let remoteUrl;
|
|
22
25
|
// ── Handshake ──────────────────────────────────────────────────────────────
|
|
23
26
|
async function establishChannel() {
|
|
27
|
+
// Pre-flight validation
|
|
28
|
+
if (!existsSync(getProxyConfigPath())) {
|
|
29
|
+
throw new Error(`No proxy config found at ${getProxyConfigPath()}. Run: drawlatch init`);
|
|
30
|
+
}
|
|
24
31
|
const config = loadProxyConfig();
|
|
25
32
|
remoteUrl = config.remoteUrl;
|
|
26
|
-
//
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
// Resolve key paths from config dir + alias
|
|
34
|
+
const alias = resolveCallerKeyAlias();
|
|
35
|
+
const callerKeysDir = path.join(getCallerKeysDir(), alias);
|
|
36
|
+
const serverKeysDir = getServerKeysDir();
|
|
37
|
+
console.error(`[mcp-proxy] Using caller alias: "${alias}"`);
|
|
38
|
+
console.error(`[mcp-proxy] Caller keys dir: ${callerKeysDir}`);
|
|
39
|
+
// Validate key paths before attempting to load
|
|
40
|
+
if (!existsSync(callerKeysDir)) {
|
|
41
|
+
throw new Error(`Caller keys not found at ${callerKeysDir}. Run: drawlatch generate-keys caller ${alias}`);
|
|
42
|
+
}
|
|
43
|
+
if (!existsSync(serverKeysDir)) {
|
|
44
|
+
throw new Error(`Server public keys not found at ${serverKeysDir}. Run: drawlatch sync`);
|
|
45
|
+
}
|
|
46
|
+
let ownKeys, remotePub;
|
|
47
|
+
try {
|
|
48
|
+
ownKeys = loadKeyBundle(callerKeysDir);
|
|
30
49
|
}
|
|
31
|
-
|
|
32
|
-
|
|
50
|
+
catch (err) {
|
|
51
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
52
|
+
throw new Error(`Failed to load caller keys from ${callerKeysDir}: ${msg}. Run: drawlatch generate-keys caller ${alias}`);
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
remotePub = loadPublicKeys(serverKeysDir);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
59
|
+
throw new Error(`Failed to load server public keys from ${serverKeysDir}: ${msg}. Run: drawlatch sync`);
|
|
33
60
|
}
|
|
34
|
-
console.error(`[mcp-proxy] Local keys dir: ${config.localKeysDir}`);
|
|
35
|
-
const ownKeys = loadKeyBundle(config.localKeysDir);
|
|
36
|
-
const remotePub = loadPublicKeys(config.remotePublicKeysDir);
|
|
37
61
|
const initiator = new HandshakeInitiator(ownKeys, remotePub);
|
|
38
62
|
// Step 1: Send HandshakeInit
|
|
39
63
|
const initMsg = initiator.createInit();
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
64
|
+
let initResp;
|
|
65
|
+
try {
|
|
66
|
+
initResp = await fetch(`${remoteUrl}/handshake/init`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'Content-Type': 'application/json' },
|
|
69
|
+
body: JSON.stringify(initMsg),
|
|
70
|
+
signal: AbortSignal.timeout(config.connectTimeout),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
75
|
+
if (msg.includes('ECONNREFUSED')) {
|
|
76
|
+
throw new Error(`Remote server is not running at ${remoteUrl}. Start it with: drawlatch start`);
|
|
77
|
+
}
|
|
78
|
+
if ((err instanceof DOMException && err.name === 'AbortError') || msg.includes('timed out')) {
|
|
79
|
+
throw new Error(`Remote server at ${remoteUrl} is not responding (timed out after ${config.connectTimeout}ms)`);
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`Failed to connect to remote server at ${remoteUrl}: ${msg}`);
|
|
82
|
+
}
|
|
46
83
|
if (!initResp.ok) {
|
|
47
84
|
const errText = await initResp.text();
|
|
85
|
+
const localFp = fingerprint(extractPublicKeys(ownKeys));
|
|
86
|
+
if (initResp.status === 401 || initResp.status === 403) {
|
|
87
|
+
console.error(`[mcp-proxy] Handshake rejected by server. Your key fingerprint: ${localFp}`);
|
|
88
|
+
console.error('[mcp-proxy] Ensure this fingerprint matches an authorized peer on the remote server.');
|
|
89
|
+
throw new Error(`Handshake rejected (${initResp.status}). Your fingerprint: ${localFp}. Check that public keys are correctly exchanged. See: drawlatch init`);
|
|
90
|
+
}
|
|
48
91
|
throw new Error(`Handshake init failed: ${initResp.status} ${errText}`);
|
|
49
92
|
}
|
|
50
93
|
const reply = await initResp.json();
|
|
@@ -85,21 +128,42 @@ async function sendEncryptedRequest(toolName, toolInput) {
|
|
|
85
128
|
};
|
|
86
129
|
// Encrypt the entire request
|
|
87
130
|
const encrypted = ch.encryptJSON(request);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
131
|
+
let resp;
|
|
132
|
+
try {
|
|
133
|
+
resp = await fetch(`${remoteUrl}/request`, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: {
|
|
136
|
+
'Content-Type': 'application/octet-stream',
|
|
137
|
+
'X-Session-Id': ch.sessionId,
|
|
138
|
+
},
|
|
139
|
+
body: new Uint8Array(encrypted),
|
|
140
|
+
signal: AbortSignal.timeout(config.requestTimeout),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
145
|
+
if (msg.includes('ECONNREFUSED')) {
|
|
146
|
+
channel = null;
|
|
147
|
+
throw new Error(`Remote server is not running at ${remoteUrl}. Start it with: drawlatch start`);
|
|
148
|
+
}
|
|
149
|
+
if ((err instanceof DOMException && err.name === 'AbortError') || msg.includes('timed out')) {
|
|
150
|
+
throw new Error(`Remote server at ${remoteUrl} is not responding (timed out after ${config.requestTimeout}ms)`);
|
|
151
|
+
}
|
|
152
|
+
channel = null;
|
|
153
|
+
throw new Error(`Failed to reach remote server at ${remoteUrl}: ${msg}`);
|
|
154
|
+
}
|
|
97
155
|
if (!resp.ok) {
|
|
98
156
|
// If session expired, re-establish
|
|
99
157
|
if (resp.status === 401) {
|
|
100
158
|
console.error('[mcp-proxy] Session expired, re-establishing...');
|
|
101
159
|
channel = null;
|
|
102
|
-
|
|
160
|
+
try {
|
|
161
|
+
return await sendEncryptedRequest(toolName, toolInput);
|
|
162
|
+
}
|
|
163
|
+
catch (reEstErr) {
|
|
164
|
+
const msg = reEstErr instanceof Error ? reEstErr.message : String(reEstErr);
|
|
165
|
+
throw new Error(`Session re-establishment failed: ${msg}`);
|
|
166
|
+
}
|
|
103
167
|
}
|
|
104
168
|
throw new Error(`Request failed: ${resp.status} ${await resp.text()}`);
|
|
105
169
|
}
|
|
@@ -129,14 +193,54 @@ server.tool('secure_request', "Make an authenticated HTTP request through the en
|
|
|
129
193
|
.optional()
|
|
130
194
|
.describe('Request headers, may contain ${VAR} placeholders'),
|
|
131
195
|
body: z.any().optional().describe('Request body (object for JSON, string for raw)'),
|
|
132
|
-
|
|
196
|
+
files: z
|
|
197
|
+
.array(z.object({
|
|
198
|
+
field: z.string().describe('Form field name (e.g., "files[0]", "file", "attachment")'),
|
|
199
|
+
path: z.string().describe('Absolute path to the file on the local filesystem'),
|
|
200
|
+
filename: z.string().describe('Filename to use in the upload'),
|
|
201
|
+
contentType: z.string().describe('MIME type (e.g., "image/png", "application/pdf")'),
|
|
202
|
+
}))
|
|
203
|
+
.optional()
|
|
204
|
+
.describe('File attachments for multipart/form-data uploads. When present, the request is sent as multipart with the JSON body as "payload_json". Use bodyFieldName to change the JSON part name.'),
|
|
205
|
+
bodyFieldName: z
|
|
206
|
+
.string()
|
|
207
|
+
.optional()
|
|
208
|
+
.describe('Form field name for the JSON body part in multipart requests (default: "payload_json"). Only used when files are present.'),
|
|
209
|
+
}, async ({ method, url, headers, body, files, bodyFieldName }) => {
|
|
133
210
|
try {
|
|
134
|
-
const
|
|
211
|
+
const toolInput = {
|
|
135
212
|
method,
|
|
136
213
|
url,
|
|
137
214
|
headers: headers ?? {},
|
|
138
215
|
body,
|
|
139
|
-
}
|
|
216
|
+
};
|
|
217
|
+
// Read and base64-encode files from the local filesystem before sending
|
|
218
|
+
// through the encrypted channel (remote server can't access local files)
|
|
219
|
+
if (files?.length) {
|
|
220
|
+
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB per file
|
|
221
|
+
const encodedFiles = await Promise.all(files.map(async ({ field, path: filePath, filename, contentType }) => {
|
|
222
|
+
if (!path.isAbsolute(filePath)) {
|
|
223
|
+
throw new Error(`File path must be absolute: ${filePath}`);
|
|
224
|
+
}
|
|
225
|
+
let stat;
|
|
226
|
+
try {
|
|
227
|
+
stat = await fs.stat(filePath);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
throw new Error(`File not found: ${filePath}`);
|
|
231
|
+
}
|
|
232
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
233
|
+
throw new Error(`File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB): ${filePath} — max ${MAX_FILE_SIZE / 1024 / 1024} MB`);
|
|
234
|
+
}
|
|
235
|
+
const buffer = await fs.readFile(filePath);
|
|
236
|
+
return { field, data: buffer.toString('base64'), filename, contentType };
|
|
237
|
+
}));
|
|
238
|
+
toolInput.files = encodedFiles;
|
|
239
|
+
if (bodyFieldName) {
|
|
240
|
+
toolInput.bodyFieldName = bodyFieldName;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const result = await sendEncryptedRequest('http_request', toolInput);
|
|
140
244
|
return {
|
|
141
245
|
content: [
|
|
142
246
|
{
|
|
@@ -339,7 +443,10 @@ server.tool('resolve_listener_options', 'Fetch dynamic options for a listener co
|
|
|
339
443
|
paramKey: z.string().describe('The field key to resolve options for (e.g., "boardId")'),
|
|
340
444
|
}, async ({ connection, paramKey }) => {
|
|
341
445
|
try {
|
|
342
|
-
const result = await sendEncryptedRequest('resolve_listener_options', {
|
|
446
|
+
const result = await sendEncryptedRequest('resolve_listener_options', {
|
|
447
|
+
connection,
|
|
448
|
+
paramKey,
|
|
449
|
+
});
|
|
343
450
|
return {
|
|
344
451
|
content: [
|
|
345
452
|
{
|
|
@@ -371,7 +478,11 @@ server.tool('control_listener', 'Start, stop, or restart an event listener for a
|
|
|
371
478
|
.describe('Instance ID for multi-instance listeners. Omit to control all instances.'),
|
|
372
479
|
}, async ({ connection, action, instance_id }) => {
|
|
373
480
|
try {
|
|
374
|
-
const result = await sendEncryptedRequest('control_listener', {
|
|
481
|
+
const result = await sendEncryptedRequest('control_listener', {
|
|
482
|
+
connection,
|
|
483
|
+
action,
|
|
484
|
+
instance_id,
|
|
485
|
+
});
|
|
375
486
|
return {
|
|
376
487
|
content: [
|
|
377
488
|
{
|
|
@@ -425,7 +536,7 @@ server.tool('get_listener_params', 'Read current listener parameter overrides fo
|
|
|
425
536
|
* Merges params into existing config. Supports creating new multi-instance listeners.
|
|
426
537
|
*/
|
|
427
538
|
// eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
|
|
428
|
-
server.tool('set_listener_params',
|
|
539
|
+
server.tool('set_listener_params', 'Add or edit listener parameter overrides for a connection. Merges params into existing config. Set create_instance to true to create a new multi-instance listener.', {
|
|
429
540
|
connection: z.string().describe('Connection alias (e.g., "trello")'),
|
|
430
541
|
instance_id: z
|
|
431
542
|
.string()
|
|
@@ -523,11 +634,135 @@ server.tool('delete_listener_instance', 'Remove a multi-instance listener instan
|
|
|
523
634
|
};
|
|
524
635
|
}
|
|
525
636
|
});
|
|
637
|
+
/**
|
|
638
|
+
* List all available connection templates with caller-specific status.
|
|
639
|
+
* Returns which connections are enabled and which secrets are configured.
|
|
640
|
+
*/
|
|
641
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
|
|
642
|
+
server.tool('list_connection_templates', 'List all available connection templates (built-in + custom). Returns template metadata, which connections are enabled for this caller, and which secrets are configured (boolean only, never values).', { _: z.string().optional().describe('unused') }, async () => {
|
|
643
|
+
try {
|
|
644
|
+
const result = await sendEncryptedRequest('list_connection_templates', {});
|
|
645
|
+
return {
|
|
646
|
+
content: [
|
|
647
|
+
{
|
|
648
|
+
type: 'text',
|
|
649
|
+
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
|
|
650
|
+
},
|
|
651
|
+
],
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
catch (err) {
|
|
655
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
656
|
+
return {
|
|
657
|
+
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
658
|
+
isError: true,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
/**
|
|
663
|
+
* Enable or disable a connection for the authenticated caller.
|
|
664
|
+
*/
|
|
665
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
|
|
666
|
+
server.tool('set_connection_enabled', 'Enable or disable a connection for the authenticated caller. When disabling, any running ingestors for the connection are stopped.', {
|
|
667
|
+
connection: z.string().describe('Connection alias (e.g., "github", "discord-bot")'),
|
|
668
|
+
enabled: z.boolean().describe('Whether to enable (true) or disable (false) the connection'),
|
|
669
|
+
}, async ({ connection, enabled }) => {
|
|
670
|
+
try {
|
|
671
|
+
const result = await sendEncryptedRequest('set_connection_enabled', { connection, enabled });
|
|
672
|
+
return {
|
|
673
|
+
content: [
|
|
674
|
+
{
|
|
675
|
+
type: 'text',
|
|
676
|
+
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
|
|
677
|
+
},
|
|
678
|
+
],
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
catch (err) {
|
|
682
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
683
|
+
return {
|
|
684
|
+
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
685
|
+
isError: true,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
/**
|
|
690
|
+
* Set or delete secrets for the authenticated caller.
|
|
691
|
+
* Uses prefixed env vars for caller isolation. Never returns secret values.
|
|
692
|
+
*/
|
|
693
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
|
|
694
|
+
server.tool('set_secrets', 'Set or delete secrets for the authenticated caller. Pass secret name → value pairs. Use empty string to delete a secret. Returns boolean status per secret (never values).', {
|
|
695
|
+
secrets: z
|
|
696
|
+
.record(z.string(), z.string())
|
|
697
|
+
.describe('Secret name → value pairs. Empty string deletes the secret.'),
|
|
698
|
+
}, async ({ secrets }) => {
|
|
699
|
+
try {
|
|
700
|
+
const result = await sendEncryptedRequest('set_secrets', { secrets });
|
|
701
|
+
return {
|
|
702
|
+
content: [
|
|
703
|
+
{
|
|
704
|
+
type: 'text',
|
|
705
|
+
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
|
|
706
|
+
},
|
|
707
|
+
],
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
catch (err) {
|
|
711
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
712
|
+
return {
|
|
713
|
+
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
714
|
+
isError: true,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
/**
|
|
719
|
+
* Check which secrets are set for the authenticated caller (never returns values).
|
|
720
|
+
*/
|
|
721
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
|
|
722
|
+
server.tool('get_secret_status', 'Check which secrets are configured for a connection. Returns boolean status for each required and optional secret (never returns values).', {
|
|
723
|
+
connection: z.string().describe('Connection alias to check secrets for (e.g., "github")'),
|
|
724
|
+
}, async ({ connection }) => {
|
|
725
|
+
try {
|
|
726
|
+
const result = await sendEncryptedRequest('get_secret_status', { connection });
|
|
727
|
+
return {
|
|
728
|
+
content: [
|
|
729
|
+
{
|
|
730
|
+
type: 'text',
|
|
731
|
+
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
|
|
732
|
+
},
|
|
733
|
+
],
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
738
|
+
return {
|
|
739
|
+
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
740
|
+
isError: true,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
});
|
|
526
744
|
// ── Start ──────────────────────────────────────────────────────────────────
|
|
527
745
|
async function main() {
|
|
528
746
|
const transport = new StdioServerTransport();
|
|
529
747
|
await server.connect(transport);
|
|
530
748
|
console.error('[mcp-proxy] MCP Secure Proxy server started (stdio transport)');
|
|
749
|
+
// Non-blocking health check: advise user if the remote server is not reachable
|
|
750
|
+
try {
|
|
751
|
+
const config = loadProxyConfig();
|
|
752
|
+
const resp = await fetch(`${config.remoteUrl}/health`, {
|
|
753
|
+
signal: AbortSignal.timeout(3000),
|
|
754
|
+
});
|
|
755
|
+
if (resp.ok) {
|
|
756
|
+
console.error(`[mcp-proxy] Remote server reachable at ${config.remoteUrl}`);
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
console.error(`[mcp-proxy] WARNING: Remote server at ${config.remoteUrl} returned ${resp.status}. Check server health.`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
const config = loadProxyConfig();
|
|
764
|
+
console.error(`[mcp-proxy] WARNING: Remote server at ${config.remoteUrl} is not reachable. Start it with: drawlatch start`);
|
|
765
|
+
}
|
|
531
766
|
}
|
|
532
767
|
main().catch((err) => {
|
|
533
768
|
console.error('[mcp-proxy] Fatal error:', err);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutating admin API (item A), mounted behind `requireAuth` at `/api/admin/*`
|
|
3
|
+
* alongside the read-only endpoints in `admin.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Every mutation reuses the canonical tool-dispatch (item D) so there is exactly
|
|
6
|
+
* one implementation of connection/secret/listener logic. Caller create/delete
|
|
7
|
+
* uses the programmatic bootstrap (item E).
|
|
8
|
+
*
|
|
9
|
+
* Security invariants (same as the read-only side):
|
|
10
|
+
* - Secret values are write-only: set via PUT, read back only as booleans.
|
|
11
|
+
* - No secret value, AES channel key, or process.env value is ever serialized.
|
|
12
|
+
* - After any config mutation, the daemon's live-reload path runs so ingestors
|
|
13
|
+
* and routes pick up the change (replacing callboard's restart banner).
|
|
14
|
+
*/
|
|
15
|
+
import express from 'express';
|
|
16
|
+
import { type RemoteServerConfig, type ResolvedRoute } from '../shared/config.js';
|
|
17
|
+
import type { IngestorManager } from './ingestors/index.js';
|
|
18
|
+
export interface AdminMutationDeps {
|
|
19
|
+
ingestorManager: () => IngestorManager;
|
|
20
|
+
loadConfig: () => RemoteServerConfig;
|
|
21
|
+
/** Resolve the live routes for a caller (with secrets) for tool dispatch. */
|
|
22
|
+
resolveRoutesForCaller: (alias: string) => ResolvedRoute[];
|
|
23
|
+
/** Re-resolve routes for all active sessions of a caller (live reload). */
|
|
24
|
+
refreshCaller: (alias: string) => void;
|
|
25
|
+
/** Register/refresh the authorized peer for a newly-created caller. */
|
|
26
|
+
reloadPeer: (alias: string) => void;
|
|
27
|
+
/** Drop the authorized peer + active sessions for a deleted caller. */
|
|
28
|
+
removePeer: (alias: string) => void;
|
|
29
|
+
/** Audit-log a sensitive admin mutation (credential issuance). Optional so
|
|
30
|
+
* tests can omit it; the daemon wires it to the request audit log. */
|
|
31
|
+
audit?: (action: string, details?: Record<string, unknown>) => void;
|
|
32
|
+
/** Endpoint URL to pin in an issued bundle when the request omits one
|
|
33
|
+
* (the public tunnel URL, or the daemon's own host:port). */
|
|
34
|
+
defaultEndpointUrl?: () => string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Mount the mutating routes onto an existing admin router.
|
|
38
|
+
*
|
|
39
|
+
* The router is already behind `requireAuth` (wired in server.ts), so these
|
|
40
|
+
* handlers can assume the request is authenticated.
|
|
41
|
+
*/
|
|
42
|
+
export declare function mountAdminMutations(router: express.Router, deps: AdminMutationDeps): void;
|
|
43
|
+
//# sourceMappingURL=admin-mutations.d.ts.map
|