@viral_vector/ig-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -0
- package/dist/bridge/protocol.d.ts +84 -0
- package/dist/bridge/protocol.js +139 -0
- package/dist/bridge/server.d.ts +12 -0
- package/dist/bridge/server.js +82 -0
- package/dist/bridge/sessionRegistry.d.ts +50 -0
- package/dist/bridge/sessionRegistry.js +175 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +114 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +10 -0
- package/dist/mcp/tools.d.ts +36 -0
- package/dist/mcp/tools.js +1466 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# ViralVector Instagram MCP Server
|
|
2
|
+
|
|
3
|
+
`@viral_vector/ig-mcp` exposes ViralVector Instagram library and live exploration tools through MCP over stdio. The same process also starts a localhost bridge server that the Chrome extension connects to.
|
|
4
|
+
|
|
5
|
+
A pairing token is required. The server refuses to start without `--token` or `VV_IG_MCP_TOKEN`.
|
|
6
|
+
|
|
7
|
+
## MCP Client Configuration
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"mcpServers": {
|
|
12
|
+
"viralvector-instagram": {
|
|
13
|
+
"command": "npx",
|
|
14
|
+
"args": ["-y", "@viral_vector/ig-mcp"],
|
|
15
|
+
"env": {
|
|
16
|
+
"VV_IG_MCP_PORT": "8765",
|
|
17
|
+
"VV_IG_MCP_TOKEN": "replace-with-extension-pairing-token"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The MCP transport uses stdout. Diagnostics and bridge status messages are written to stderr.
|
|
25
|
+
|
|
26
|
+
## Extension Pairing
|
|
27
|
+
|
|
28
|
+
1. Open the ViralVector extension dashboard.
|
|
29
|
+
2. Go to Settings.
|
|
30
|
+
3. Enable the local MCP bridge.
|
|
31
|
+
4. Set the bridge port to the same `VV_IG_MCP_PORT` value.
|
|
32
|
+
5. Copy the pairing token into `VV_IG_MCP_TOKEN`.
|
|
33
|
+
6. Start or restart the MCP client.
|
|
34
|
+
|
|
35
|
+
The bridge server binds only to `127.0.0.1` and accepts WebSocket connections on `/viral-vector-bridge`.
|
|
36
|
+
|
|
37
|
+
Common recovery paths:
|
|
38
|
+
|
|
39
|
+
- `extension_disconnected`: open Chrome, load the ViralVector extension, enable Local MCP bridge in Settings, and verify the port matches `VV_IG_MCP_PORT`.
|
|
40
|
+
- `auth_failed`: copy the same pairing token into extension Settings and `VV_IG_MCP_TOKEN`; restart the MCP client after changing environment variables.
|
|
41
|
+
- `instagram_session_unavailable`: open Instagram in the same Chrome profile and sign in before calling session-dependent tools.
|
|
42
|
+
|
|
43
|
+
## Recommended Tools
|
|
44
|
+
|
|
45
|
+
Recommended tools use `snake_case` names and keep local-library reads, live Instagram exploration, and explicit local mutations separate.
|
|
46
|
+
|
|
47
|
+
### Viral Vector Local Library: `vv_*`
|
|
48
|
+
|
|
49
|
+
`vv_*` tools query only data already saved in the ViralVector extension library, historical snapshots, saved media/comments, exports, and existing operations. They do not perform Instagram live search and they do not implicitly create tracked targets.
|
|
50
|
+
|
|
51
|
+
- `vv_status`
|
|
52
|
+
- `vv_library_query`
|
|
53
|
+
- `vv_account_query`
|
|
54
|
+
- `vv_account_audience_query`
|
|
55
|
+
- `vv_hashtag_query`
|
|
56
|
+
- `vv_media_query`
|
|
57
|
+
- `vv_comments_query`
|
|
58
|
+
- `vv_operation_run`
|
|
59
|
+
- `vv_operation_query`
|
|
60
|
+
|
|
61
|
+
`vv_operation_run` is the only explicit side-effect entrypoint. It supports these `kind` values:
|
|
62
|
+
|
|
63
|
+
- `account_track`
|
|
64
|
+
- `account_update_role`
|
|
65
|
+
- `account_untrack`
|
|
66
|
+
- `account_refresh`
|
|
67
|
+
- `hashtag_track`
|
|
68
|
+
- `hashtag_untrack`
|
|
69
|
+
- `hashtag_refresh`
|
|
70
|
+
- `media_comments_refresh`
|
|
71
|
+
- `media_download`
|
|
72
|
+
- `export_create`
|
|
73
|
+
|
|
74
|
+
`account_untrack` and `hashtag_untrack` delete local tracked targets only. They do not delete Instagram content. Use `dryRun: true` to validate inputs and preview effects without calling mutating bridge capabilities.
|
|
75
|
+
|
|
76
|
+
### Instagram Live Exploration: `ig_*`
|
|
77
|
+
|
|
78
|
+
`ig_*` tools are temporary Instagram exploration entrypoints. They are read-only from the plugin perspective: they must not save targets, start tracking, refresh snapshots, or write to the local library. Returned candidates must identify whether the object is already known locally with flags such as `inLibrary` or `isTracked`.
|
|
79
|
+
|
|
80
|
+
- `ig_search`
|
|
81
|
+
- Parameters: `query`, optional `targets` (`accounts`, `hashtags`), optional `limit`.
|
|
82
|
+
- Purpose: live candidate search across Instagram accounts and hashtags.
|
|
83
|
+
- `ig_lookup`
|
|
84
|
+
- Parameters: `type` (`account`, `hashtag`), one identifier (`username` or `hashtag`), optional `include` (`recent_media`, `related`) and `mediaLimit`.
|
|
85
|
+
- Purpose: live lightweight lookup for one Instagram account or hashtag.
|
|
86
|
+
|
|
87
|
+
Live exploration is read-only and never persists results. Saving or refreshing a chosen result remains explicit:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
ig_search -> ig_lookup -> vv_operation_run(kind: "account_track") -> vv_account_query
|
|
91
|
+
ig_lookup(type: "hashtag", hashtag) -> vv_operation_run(kind: "hashtag_track") -> vv_hashtag_query
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The MCP facade hides extension internals such as collection tasks, export repositories, and IndexedDB storage shapes while preserving the underlying bridge protocol.
|
|
95
|
+
|
|
96
|
+
Instagram requests share a single extension-side queue. Defaults are configured in `../src/config/instagramRequestSettings.ts`: one request at a time, a 1-second minimum interval, 30-second in-memory duplicate reuse, and persisted cooldowns after rate-limit, feedback, or challenge responses.
|
|
97
|
+
|
|
98
|
+
## CLI
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
vv-ig-mcp --port 8765 --token replace-with-extension-pairing-token
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Environment variables:
|
|
105
|
+
|
|
106
|
+
- `VV_IG_MCP_PORT`
|
|
107
|
+
- `VV_IG_MCP_TOKEN`
|
|
108
|
+
- `VV_IG_MCP_REQUEST_TIMEOUT_MS`
|
|
109
|
+
|
|
110
|
+
## Local Verification
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pnpm install
|
|
114
|
+
pnpm build
|
|
115
|
+
pnpm test
|
|
116
|
+
pnpm smoke:mcp-v1
|
|
117
|
+
npm pack
|
|
118
|
+
npx -y ./viral_vector-ig-mcp-*.tgz --help
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Formal npm publishing is intentionally outside this repository workflow.
|
|
122
|
+
|
|
123
|
+
See `../../docs/ig-extension-mcp-v1-smoke.md` for the full automated smoke, manual Chrome checklist, and expected error samples.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export declare const BRIDGE_PROTOCOL_VERSION = 1;
|
|
2
|
+
export declare const BRIDGE_PATH = "/viral-vector-bridge";
|
|
3
|
+
export declare const CAPABILITIES: {
|
|
4
|
+
readonly SOCIAL_SESSION_STATUS: "social.session.status";
|
|
5
|
+
readonly SOCIAL_INSTAGRAM_LOOKUP_ACCOUNT: "social.instagram.lookupAccount";
|
|
6
|
+
readonly SOCIAL_INSTAGRAM_LOOKUP_HASHTAG: "social.instagram.lookupHashtag";
|
|
7
|
+
readonly SOCIAL_CONTEXT_QUERY_AUDIENCE_MEMBERS: "social.context.queryAudienceMembers";
|
|
8
|
+
readonly SOCIAL_CONTEXT_LIST_ACCOUNTS: "social.context.listAccounts";
|
|
9
|
+
readonly SOCIAL_CONTEXT_SEARCH_ACCOUNTS: "social.context.searchAccounts";
|
|
10
|
+
readonly SOCIAL_CONTEXT_SEARCH_HASHTAGS: "social.context.searchHashtags";
|
|
11
|
+
readonly SOCIAL_CONTEXT_GET_ACCOUNT_DETAIL: "social.context.getAccountDetail";
|
|
12
|
+
readonly SOCIAL_CONTEXT_GET_HASHTAG_DETAIL: "social.context.getHashtagDetail";
|
|
13
|
+
readonly SOCIAL_CONTEXT_GET_POST_DETAIL: "social.context.getPostDetail";
|
|
14
|
+
readonly SOCIAL_CONTEXT_UPSERT_ACCOUNT_TARGET: "social.context.upsertAccountTarget";
|
|
15
|
+
readonly SOCIAL_CONTEXT_UPSERT_HASHTAG_TARGET: "social.context.upsertHashtagTarget";
|
|
16
|
+
readonly SOCIAL_CONTEXT_UPDATE_ACCOUNT_ROLE: "social.context.updateAccountRole";
|
|
17
|
+
readonly SOCIAL_CONTEXT_DELETE_ACCOUNT: "social.context.deleteAccount";
|
|
18
|
+
readonly SOCIAL_CONTEXT_DELETE_HASHTAG: "social.context.deleteHashtag";
|
|
19
|
+
readonly SOCIAL_CONTEXT_LIST_EXPORTS: "social.context.listExports";
|
|
20
|
+
readonly SOCIAL_CONTEXT_EXPORT_CSV: "social.context.exportCsv";
|
|
21
|
+
readonly SOCIAL_COLLECTION_START: "social.collection.start";
|
|
22
|
+
readonly SOCIAL_COLLECTION_LIST_TASKS: "social.collection.listTasks";
|
|
23
|
+
readonly SOCIAL_COLLECTION_GET_ACTIVE_TASK: "social.collection.getActiveTask";
|
|
24
|
+
readonly SOCIAL_MEDIA_DOWNLOAD: "social.media.download";
|
|
25
|
+
};
|
|
26
|
+
export type CapabilityName = (typeof CAPABILITIES)[keyof typeof CAPABILITIES];
|
|
27
|
+
export declare const BRIDGE_ERROR_CODES: {
|
|
28
|
+
readonly MALFORMED_FRAME: "malformed_frame";
|
|
29
|
+
readonly EXTENSION_DISCONNECTED: "extension_disconnected";
|
|
30
|
+
readonly BRIDGE_DISABLED: "bridge_disabled";
|
|
31
|
+
readonly AUTH_FAILED: "auth_failed";
|
|
32
|
+
readonly VALIDATION_FAILED: "validation_failed";
|
|
33
|
+
readonly UNSUPPORTED_CAPABILITY: "unsupported_capability";
|
|
34
|
+
readonly WRITE_CAPABILITY_DISABLED: "write_capability_disabled";
|
|
35
|
+
readonly TOOL_TIMEOUT: "tool_timeout";
|
|
36
|
+
readonly INSTAGRAM_SESSION_UNAVAILABLE: "instagram_session_unavailable";
|
|
37
|
+
readonly RATE_LIMITED: "rate_limited";
|
|
38
|
+
readonly INSTAGRAM_CHALLENGE: "instagram_challenge";
|
|
39
|
+
readonly HANDLER_FAILED: "handler_failed";
|
|
40
|
+
readonly DUPLICATE_CONNECTION: "duplicate_connection";
|
|
41
|
+
};
|
|
42
|
+
export type BridgeErrorCode = (typeof BRIDGE_ERROR_CODES)[keyof typeof BRIDGE_ERROR_CODES];
|
|
43
|
+
export type BridgeHelloFrame = {
|
|
44
|
+
type: 'hello';
|
|
45
|
+
id: string;
|
|
46
|
+
protocolVersion: typeof BRIDGE_PROTOCOL_VERSION;
|
|
47
|
+
clientId: string;
|
|
48
|
+
extensionVersion?: string;
|
|
49
|
+
token?: string;
|
|
50
|
+
};
|
|
51
|
+
export type BridgeRequestFrame = {
|
|
52
|
+
type: 'request';
|
|
53
|
+
id: string;
|
|
54
|
+
capability: string;
|
|
55
|
+
input?: unknown;
|
|
56
|
+
token?: string;
|
|
57
|
+
};
|
|
58
|
+
export type BridgeResponseFrame = {
|
|
59
|
+
type: 'response';
|
|
60
|
+
id: string;
|
|
61
|
+
capability: string;
|
|
62
|
+
ok: true;
|
|
63
|
+
data: unknown;
|
|
64
|
+
};
|
|
65
|
+
export type BridgeErrorFrame = {
|
|
66
|
+
type: 'error';
|
|
67
|
+
id: string | null;
|
|
68
|
+
capability: string | null;
|
|
69
|
+
ok: false;
|
|
70
|
+
error: {
|
|
71
|
+
code: BridgeErrorCode;
|
|
72
|
+
message: string;
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
export type BridgeConnectionStatusFrame = {
|
|
76
|
+
type: 'connection_status';
|
|
77
|
+
status: string;
|
|
78
|
+
lastConnectedAt: string | null;
|
|
79
|
+
lastError: string | null;
|
|
80
|
+
};
|
|
81
|
+
export type BridgeFrame = BridgeHelloFrame | BridgeRequestFrame | BridgeResponseFrame | BridgeErrorFrame | BridgeConnectionStatusFrame;
|
|
82
|
+
export declare function parseBridgeFrame(value: unknown): BridgeFrame | BridgeErrorFrame;
|
|
83
|
+
export declare function createBridgeErrorFrame(id: string | null, capability: string | null, code: BridgeErrorCode, message: string): BridgeErrorFrame;
|
|
84
|
+
export declare function createBridgeRequestFrame(id: string, capability: CapabilityName, input: unknown, token: string | null): BridgeRequestFrame;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
export const BRIDGE_PROTOCOL_VERSION = 1;
|
|
2
|
+
export const BRIDGE_PATH = '/viral-vector-bridge';
|
|
3
|
+
export const CAPABILITIES = {
|
|
4
|
+
SOCIAL_SESSION_STATUS: 'social.session.status',
|
|
5
|
+
SOCIAL_INSTAGRAM_LOOKUP_ACCOUNT: 'social.instagram.lookupAccount',
|
|
6
|
+
SOCIAL_INSTAGRAM_LOOKUP_HASHTAG: 'social.instagram.lookupHashtag',
|
|
7
|
+
SOCIAL_CONTEXT_QUERY_AUDIENCE_MEMBERS: 'social.context.queryAudienceMembers',
|
|
8
|
+
SOCIAL_CONTEXT_LIST_ACCOUNTS: 'social.context.listAccounts',
|
|
9
|
+
SOCIAL_CONTEXT_SEARCH_ACCOUNTS: 'social.context.searchAccounts',
|
|
10
|
+
SOCIAL_CONTEXT_SEARCH_HASHTAGS: 'social.context.searchHashtags',
|
|
11
|
+
SOCIAL_CONTEXT_GET_ACCOUNT_DETAIL: 'social.context.getAccountDetail',
|
|
12
|
+
SOCIAL_CONTEXT_GET_HASHTAG_DETAIL: 'social.context.getHashtagDetail',
|
|
13
|
+
SOCIAL_CONTEXT_GET_POST_DETAIL: 'social.context.getPostDetail',
|
|
14
|
+
SOCIAL_CONTEXT_UPSERT_ACCOUNT_TARGET: 'social.context.upsertAccountTarget',
|
|
15
|
+
SOCIAL_CONTEXT_UPSERT_HASHTAG_TARGET: 'social.context.upsertHashtagTarget',
|
|
16
|
+
SOCIAL_CONTEXT_UPDATE_ACCOUNT_ROLE: 'social.context.updateAccountRole',
|
|
17
|
+
SOCIAL_CONTEXT_DELETE_ACCOUNT: 'social.context.deleteAccount',
|
|
18
|
+
SOCIAL_CONTEXT_DELETE_HASHTAG: 'social.context.deleteHashtag',
|
|
19
|
+
SOCIAL_CONTEXT_LIST_EXPORTS: 'social.context.listExports',
|
|
20
|
+
SOCIAL_CONTEXT_EXPORT_CSV: 'social.context.exportCsv',
|
|
21
|
+
SOCIAL_COLLECTION_START: 'social.collection.start',
|
|
22
|
+
SOCIAL_COLLECTION_LIST_TASKS: 'social.collection.listTasks',
|
|
23
|
+
SOCIAL_COLLECTION_GET_ACTIVE_TASK: 'social.collection.getActiveTask',
|
|
24
|
+
SOCIAL_MEDIA_DOWNLOAD: 'social.media.download'
|
|
25
|
+
};
|
|
26
|
+
export const BRIDGE_ERROR_CODES = {
|
|
27
|
+
MALFORMED_FRAME: 'malformed_frame',
|
|
28
|
+
EXTENSION_DISCONNECTED: 'extension_disconnected',
|
|
29
|
+
BRIDGE_DISABLED: 'bridge_disabled',
|
|
30
|
+
AUTH_FAILED: 'auth_failed',
|
|
31
|
+
VALIDATION_FAILED: 'validation_failed',
|
|
32
|
+
UNSUPPORTED_CAPABILITY: 'unsupported_capability',
|
|
33
|
+
WRITE_CAPABILITY_DISABLED: 'write_capability_disabled',
|
|
34
|
+
TOOL_TIMEOUT: 'tool_timeout',
|
|
35
|
+
INSTAGRAM_SESSION_UNAVAILABLE: 'instagram_session_unavailable',
|
|
36
|
+
RATE_LIMITED: 'rate_limited',
|
|
37
|
+
INSTAGRAM_CHALLENGE: 'instagram_challenge',
|
|
38
|
+
HANDLER_FAILED: 'handler_failed',
|
|
39
|
+
DUPLICATE_CONNECTION: 'duplicate_connection'
|
|
40
|
+
};
|
|
41
|
+
export function parseBridgeFrame(value) {
|
|
42
|
+
if (typeof value === 'string') {
|
|
43
|
+
try {
|
|
44
|
+
return parseBridgeFrame(JSON.parse(value));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return createBridgeErrorFrame(null, null, BRIDGE_ERROR_CODES.MALFORMED_FRAME, 'Bridge frame must be valid JSON.');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
51
|
+
return createBridgeErrorFrame(null, null, BRIDGE_ERROR_CODES.MALFORMED_FRAME, 'Bridge frame must be an object.');
|
|
52
|
+
}
|
|
53
|
+
const candidate = value;
|
|
54
|
+
switch (candidate.type) {
|
|
55
|
+
case 'hello':
|
|
56
|
+
return isHelloFrame(candidate)
|
|
57
|
+
? candidate
|
|
58
|
+
: createBridgeErrorFrame(frameId(candidate), null, BRIDGE_ERROR_CODES.MALFORMED_FRAME, 'Bridge hello frame is malformed.');
|
|
59
|
+
case 'response':
|
|
60
|
+
return isResponseFrame(candidate)
|
|
61
|
+
? candidate
|
|
62
|
+
: createBridgeErrorFrame(frameId(candidate), frameCapability(candidate), BRIDGE_ERROR_CODES.MALFORMED_FRAME, 'Bridge response frame is malformed.');
|
|
63
|
+
case 'error':
|
|
64
|
+
return isErrorFrame(candidate)
|
|
65
|
+
? candidate
|
|
66
|
+
: createBridgeErrorFrame(frameId(candidate), frameCapability(candidate), BRIDGE_ERROR_CODES.MALFORMED_FRAME, 'Bridge error frame is malformed.');
|
|
67
|
+
case 'connection_status':
|
|
68
|
+
return isConnectionStatusFrame(candidate)
|
|
69
|
+
? candidate
|
|
70
|
+
: createBridgeErrorFrame(null, null, BRIDGE_ERROR_CODES.MALFORMED_FRAME, 'Bridge connection status frame is malformed.');
|
|
71
|
+
case 'request':
|
|
72
|
+
return isRequestFrame(candidate)
|
|
73
|
+
? candidate
|
|
74
|
+
: createBridgeErrorFrame(frameId(candidate), frameCapability(candidate), BRIDGE_ERROR_CODES.MALFORMED_FRAME, 'Bridge request frame is malformed.');
|
|
75
|
+
default:
|
|
76
|
+
return createBridgeErrorFrame(frameId(candidate), frameCapability(candidate), BRIDGE_ERROR_CODES.MALFORMED_FRAME, 'Unsupported bridge frame type.');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function createBridgeErrorFrame(id, capability, code, message) {
|
|
80
|
+
return {
|
|
81
|
+
type: 'error',
|
|
82
|
+
id,
|
|
83
|
+
capability,
|
|
84
|
+
ok: false,
|
|
85
|
+
error: { code, message }
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function createBridgeRequestFrame(id, capability, input, token) {
|
|
89
|
+
return {
|
|
90
|
+
type: 'request',
|
|
91
|
+
id,
|
|
92
|
+
capability,
|
|
93
|
+
input,
|
|
94
|
+
token: token ?? undefined
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function isHelloFrame(value) {
|
|
98
|
+
return value.type === 'hello'
|
|
99
|
+
&& typeof value.id === 'string'
|
|
100
|
+
&& value.id.trim() !== ''
|
|
101
|
+
&& value.protocolVersion === BRIDGE_PROTOCOL_VERSION
|
|
102
|
+
&& typeof value.clientId === 'string'
|
|
103
|
+
&& value.clientId.trim() !== ''
|
|
104
|
+
&& (value.extensionVersion === undefined || typeof value.extensionVersion === 'string');
|
|
105
|
+
}
|
|
106
|
+
function isRequestFrame(value) {
|
|
107
|
+
return value.type === 'request'
|
|
108
|
+
&& typeof value.id === 'string'
|
|
109
|
+
&& value.id.trim() !== ''
|
|
110
|
+
&& typeof value.capability === 'string'
|
|
111
|
+
&& value.capability.trim() !== '';
|
|
112
|
+
}
|
|
113
|
+
function isResponseFrame(value) {
|
|
114
|
+
return value.type === 'response'
|
|
115
|
+
&& value.ok === true
|
|
116
|
+
&& typeof value.id === 'string'
|
|
117
|
+
&& value.id.trim() !== ''
|
|
118
|
+
&& typeof value.capability === 'string'
|
|
119
|
+
&& value.capability.trim() !== '';
|
|
120
|
+
}
|
|
121
|
+
function isErrorFrame(value) {
|
|
122
|
+
return value.type === 'error'
|
|
123
|
+
&& value.ok === false
|
|
124
|
+
&& value.error !== undefined
|
|
125
|
+
&& typeof value.error.code === 'string'
|
|
126
|
+
&& typeof value.error.message === 'string';
|
|
127
|
+
}
|
|
128
|
+
function isConnectionStatusFrame(value) {
|
|
129
|
+
return value.type === 'connection_status'
|
|
130
|
+
&& typeof value.status === 'string'
|
|
131
|
+
&& (value.lastConnectedAt === null || typeof value.lastConnectedAt === 'string')
|
|
132
|
+
&& (value.lastError === null || typeof value.lastError === 'string');
|
|
133
|
+
}
|
|
134
|
+
function frameId(value) {
|
|
135
|
+
return typeof value.id === 'string' && value.id.trim() !== '' ? value.id : null;
|
|
136
|
+
}
|
|
137
|
+
function frameCapability(value) {
|
|
138
|
+
return typeof value.capability === 'string' && value.capability.trim() !== '' ? value.capability : null;
|
|
139
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ExtensionSessionRegistry } from './sessionRegistry.js';
|
|
2
|
+
export type LocalBridgeServerOptions = {
|
|
3
|
+
port: number;
|
|
4
|
+
registry: ExtensionSessionRegistry;
|
|
5
|
+
host?: '127.0.0.1';
|
|
6
|
+
log?: (message: string, context?: unknown) => void;
|
|
7
|
+
};
|
|
8
|
+
export type LocalBridgeServer = {
|
|
9
|
+
close: () => Promise<void>;
|
|
10
|
+
port: () => number;
|
|
11
|
+
};
|
|
12
|
+
export declare function startLocalBridgeServer({ port, registry, host, log }: LocalBridgeServerOptions): Promise<LocalBridgeServer>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws';
|
|
2
|
+
import { BRIDGE_ERROR_CODES, BRIDGE_PATH, createBridgeErrorFrame, parseBridgeFrame } from './protocol.js';
|
|
3
|
+
export async function startLocalBridgeServer({ port, registry, host = '127.0.0.1', log = () => { } }) {
|
|
4
|
+
const server = new WebSocketServer({ host, port, path: BRIDGE_PATH });
|
|
5
|
+
const sockets = new Set();
|
|
6
|
+
server.on('connection', (socket) => {
|
|
7
|
+
sockets.add(socket);
|
|
8
|
+
let authorized = false;
|
|
9
|
+
const helloTimer = setTimeout(() => {
|
|
10
|
+
if (!authorized) {
|
|
11
|
+
sendError(socket, createBridgeErrorFrame(null, null, BRIDGE_ERROR_CODES.AUTH_FAILED, 'Bridge hello was not received before timeout.'));
|
|
12
|
+
socket.close(1008, 'hello_timeout');
|
|
13
|
+
}
|
|
14
|
+
}, 5_000);
|
|
15
|
+
socket.on('message', (data) => {
|
|
16
|
+
const raw = data.toString('utf8');
|
|
17
|
+
if (!authorized) {
|
|
18
|
+
const frame = parseBridgeFrame(raw);
|
|
19
|
+
if (frame.type !== 'hello') {
|
|
20
|
+
sendError(socket, frame.type === 'error'
|
|
21
|
+
? frame
|
|
22
|
+
: createBridgeErrorFrame(null, null, BRIDGE_ERROR_CODES.AUTH_FAILED, 'First bridge frame must be hello.'));
|
|
23
|
+
socket.close(1008, 'hello_required');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const error = registry.attach(socket, frame);
|
|
27
|
+
if (error) {
|
|
28
|
+
sendError(socket, error);
|
|
29
|
+
socket.close(1008, 'auth_failed');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
authorized = true;
|
|
33
|
+
clearTimeout(helloTimer);
|
|
34
|
+
log('Extension bridge connected', {
|
|
35
|
+
clientId: frame.clientId,
|
|
36
|
+
extensionVersion: frame.extensionVersion ?? null
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const error = registry.handleMessage(socket, raw);
|
|
41
|
+
if (error) {
|
|
42
|
+
sendError(socket, error);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
socket.on('close', () => {
|
|
46
|
+
clearTimeout(helloTimer);
|
|
47
|
+
sockets.delete(socket);
|
|
48
|
+
registry.detach(socket);
|
|
49
|
+
log('Extension bridge disconnected');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
await new Promise((resolve, reject) => {
|
|
53
|
+
server.once('listening', resolve);
|
|
54
|
+
server.once('error', reject);
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
close: () => new Promise((resolve, reject) => {
|
|
58
|
+
for (const socket of sockets) {
|
|
59
|
+
registry.detach(socket);
|
|
60
|
+
socket.close(1001, 'server_shutdown');
|
|
61
|
+
socket.terminate();
|
|
62
|
+
}
|
|
63
|
+
sockets.clear();
|
|
64
|
+
server.close((error) => {
|
|
65
|
+
if (error) {
|
|
66
|
+
reject(error);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
resolve();
|
|
70
|
+
});
|
|
71
|
+
}),
|
|
72
|
+
port: () => {
|
|
73
|
+
const address = server.address();
|
|
74
|
+
return typeof address === 'object' && address ? address.port : port;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function sendError(socket, frame) {
|
|
79
|
+
if (socket.readyState === socket.OPEN) {
|
|
80
|
+
socket.send(JSON.stringify(frame));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { WebSocket } from 'ws';
|
|
2
|
+
import { type BridgeConnectionStatusFrame, type BridgeErrorFrame, type BridgeHelloFrame, type CapabilityName } from './protocol.js';
|
|
3
|
+
export type BridgeRequestResult = {
|
|
4
|
+
ok: true;
|
|
5
|
+
requestId: string;
|
|
6
|
+
capability: CapabilityName;
|
|
7
|
+
data: unknown;
|
|
8
|
+
} | {
|
|
9
|
+
ok: false;
|
|
10
|
+
requestId: string | null;
|
|
11
|
+
capability: string | null;
|
|
12
|
+
error: BridgeErrorFrame['error'];
|
|
13
|
+
};
|
|
14
|
+
export type ExtensionConnectionStatus = {
|
|
15
|
+
connected: boolean;
|
|
16
|
+
clientId: string | null;
|
|
17
|
+
extensionVersion: string | null;
|
|
18
|
+
connectedAt: string | null;
|
|
19
|
+
lastSeenAt: string | null;
|
|
20
|
+
lastStatus: BridgeConnectionStatusFrame | null;
|
|
21
|
+
pendingRequests: number;
|
|
22
|
+
};
|
|
23
|
+
export type ExtensionSessionRegistryOptions = {
|
|
24
|
+
token: string;
|
|
25
|
+
requestTimeoutMs?: number;
|
|
26
|
+
now?: () => Date;
|
|
27
|
+
setTimer?: (callback: () => void, delayMs: number) => unknown;
|
|
28
|
+
clearTimer?: (timer: unknown) => void;
|
|
29
|
+
};
|
|
30
|
+
export declare class ExtensionSessionRegistry {
|
|
31
|
+
private readonly token;
|
|
32
|
+
private readonly requestTimeoutMs;
|
|
33
|
+
private readonly now;
|
|
34
|
+
private readonly setTimer;
|
|
35
|
+
private readonly clearTimer;
|
|
36
|
+
private session;
|
|
37
|
+
private lastStatus;
|
|
38
|
+
private sequence;
|
|
39
|
+
private readonly pending;
|
|
40
|
+
constructor(options: ExtensionSessionRegistryOptions);
|
|
41
|
+
attach(socket: WebSocket, hello: BridgeHelloFrame): BridgeErrorFrame | null;
|
|
42
|
+
detach(socket: WebSocket): void;
|
|
43
|
+
failPending(error: BridgeErrorFrame['error']): void;
|
|
44
|
+
handleMessage(socket: WebSocket, value: unknown): BridgeErrorFrame | null;
|
|
45
|
+
getStatus(): ExtensionConnectionStatus;
|
|
46
|
+
request(capability: CapabilityName, input?: unknown): Promise<BridgeRequestResult>;
|
|
47
|
+
private isConnected;
|
|
48
|
+
private resolvePendingResponse;
|
|
49
|
+
private resolvePendingError;
|
|
50
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { BRIDGE_ERROR_CODES, CAPABILITIES, createBridgeErrorFrame, createBridgeRequestFrame, parseBridgeFrame } from './protocol.js';
|
|
2
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
3
|
+
export class ExtensionSessionRegistry {
|
|
4
|
+
token;
|
|
5
|
+
requestTimeoutMs;
|
|
6
|
+
now;
|
|
7
|
+
setTimer;
|
|
8
|
+
clearTimer;
|
|
9
|
+
session = null;
|
|
10
|
+
lastStatus = null;
|
|
11
|
+
sequence = 0;
|
|
12
|
+
pending = new Map();
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.token = options.token;
|
|
15
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
16
|
+
this.now = options.now ?? (() => new Date());
|
|
17
|
+
this.setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
18
|
+
this.clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer));
|
|
19
|
+
}
|
|
20
|
+
attach(socket, hello) {
|
|
21
|
+
if (hello.token !== this.token) {
|
|
22
|
+
return createBridgeErrorFrame(hello.id, null, BRIDGE_ERROR_CODES.AUTH_FAILED, 'Bridge pairing token is missing or invalid.');
|
|
23
|
+
}
|
|
24
|
+
if (this.session && this.session.socket !== socket) {
|
|
25
|
+
this.session.socket.send(JSON.stringify(createBridgeErrorFrame(null, null, BRIDGE_ERROR_CODES.DUPLICATE_CONNECTION, 'Another extension bridge connection replaced this session.')));
|
|
26
|
+
this.failPending({
|
|
27
|
+
code: BRIDGE_ERROR_CODES.DUPLICATE_CONNECTION,
|
|
28
|
+
message: 'Another extension bridge connection replaced this session.'
|
|
29
|
+
});
|
|
30
|
+
this.session.socket.close(4000, 'duplicate_connection');
|
|
31
|
+
}
|
|
32
|
+
const now = this.now().toISOString();
|
|
33
|
+
this.session = {
|
|
34
|
+
socket,
|
|
35
|
+
clientId: hello.clientId,
|
|
36
|
+
extensionVersion: hello.extensionVersion ?? null,
|
|
37
|
+
connectedAt: now,
|
|
38
|
+
lastSeenAt: now
|
|
39
|
+
};
|
|
40
|
+
this.lastStatus = null;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
detach(socket) {
|
|
44
|
+
if (!this.session || this.session.socket !== socket) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
this.session = null;
|
|
48
|
+
this.failPending({
|
|
49
|
+
code: BRIDGE_ERROR_CODES.EXTENSION_DISCONNECTED,
|
|
50
|
+
message: 'Extension disconnected before the bridge request completed.'
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
failPending(error) {
|
|
54
|
+
for (const [id, pending] of this.pending) {
|
|
55
|
+
this.clearTimer(pending.timer);
|
|
56
|
+
pending.resolve({
|
|
57
|
+
ok: false,
|
|
58
|
+
requestId: id,
|
|
59
|
+
capability: pending.capability,
|
|
60
|
+
error
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
this.pending.clear();
|
|
64
|
+
}
|
|
65
|
+
handleMessage(socket, value) {
|
|
66
|
+
const frame = parseBridgeFrame(value);
|
|
67
|
+
if (frame.type === 'error') {
|
|
68
|
+
this.resolvePendingError(frame);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (!this.session || this.session.socket !== socket) {
|
|
72
|
+
return createBridgeErrorFrame(null, null, BRIDGE_ERROR_CODES.EXTENSION_DISCONNECTED, 'Bridge extension session is not active.');
|
|
73
|
+
}
|
|
74
|
+
this.session.lastSeenAt = this.now().toISOString();
|
|
75
|
+
if (frame.type === 'response') {
|
|
76
|
+
this.resolvePendingResponse(frame);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (frame.type === 'connection_status') {
|
|
80
|
+
this.lastStatus = frame;
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (frame.type === 'request') {
|
|
84
|
+
return createBridgeErrorFrame(frame.id, frame.capability, BRIDGE_ERROR_CODES.UNSUPPORTED_CAPABILITY, 'MCP bridge server does not accept extension-initiated capability requests.');
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
getStatus() {
|
|
89
|
+
return {
|
|
90
|
+
connected: this.isConnected(),
|
|
91
|
+
clientId: this.session?.clientId ?? null,
|
|
92
|
+
extensionVersion: this.session?.extensionVersion ?? null,
|
|
93
|
+
connectedAt: this.session?.connectedAt ?? null,
|
|
94
|
+
lastSeenAt: this.session?.lastSeenAt ?? null,
|
|
95
|
+
lastStatus: this.lastStatus,
|
|
96
|
+
pendingRequests: this.pending.size
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async request(capability, input = {}) {
|
|
100
|
+
if (!Object.values(CAPABILITIES).includes(capability)) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
requestId: null,
|
|
104
|
+
capability,
|
|
105
|
+
error: {
|
|
106
|
+
code: BRIDGE_ERROR_CODES.UNSUPPORTED_CAPABILITY,
|
|
107
|
+
message: `Unsupported MCP bridge capability: ${capability}`
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (!this.isConnected() || !this.session) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
requestId: null,
|
|
115
|
+
capability,
|
|
116
|
+
error: {
|
|
117
|
+
code: BRIDGE_ERROR_CODES.EXTENSION_DISCONNECTED,
|
|
118
|
+
message: 'ViralVector extension is not connected to the local MCP bridge.'
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const requestId = `mcp-${Date.now().toString(36)}-${(++this.sequence).toString(36)}`;
|
|
123
|
+
const frame = createBridgeRequestFrame(requestId, capability, input, this.token);
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
const timer = this.setTimer(() => {
|
|
126
|
+
this.pending.delete(requestId);
|
|
127
|
+
resolve({
|
|
128
|
+
ok: false,
|
|
129
|
+
requestId,
|
|
130
|
+
capability,
|
|
131
|
+
error: {
|
|
132
|
+
code: BRIDGE_ERROR_CODES.TOOL_TIMEOUT,
|
|
133
|
+
message: `Bridge request timed out after ${this.requestTimeoutMs}ms.`
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}, this.requestTimeoutMs);
|
|
137
|
+
this.pending.set(requestId, { capability, timer, resolve });
|
|
138
|
+
this.session?.socket.send(JSON.stringify(frame));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
isConnected() {
|
|
142
|
+
return Boolean(this.session && this.session.socket.readyState === this.session.socket.OPEN);
|
|
143
|
+
}
|
|
144
|
+
resolvePendingResponse(frame) {
|
|
145
|
+
const pending = this.pending.get(frame.id);
|
|
146
|
+
if (!pending) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
this.pending.delete(frame.id);
|
|
150
|
+
this.clearTimer(pending.timer);
|
|
151
|
+
pending.resolve({
|
|
152
|
+
ok: true,
|
|
153
|
+
requestId: frame.id,
|
|
154
|
+
capability: pending.capability,
|
|
155
|
+
data: frame.data
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
resolvePendingError(frame) {
|
|
159
|
+
if (!frame.id) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const pending = this.pending.get(frame.id);
|
|
163
|
+
if (!pending) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.pending.delete(frame.id);
|
|
167
|
+
this.clearTimer(pending.timer);
|
|
168
|
+
pending.resolve({
|
|
169
|
+
ok: false,
|
|
170
|
+
requestId: frame.id,
|
|
171
|
+
capability: frame.capability,
|
|
172
|
+
error: frame.error
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
package/dist/cli.d.ts
ADDED