Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -709,12 +709,53 @@ ws.onmessage = (e) => {
<tr><td><code>ask_claude</code></td><td>Query Claude (supports file upload)</td></tr>
<tr><td><code>ask_gemini</code></td><td>Query Gemini (supports file upload)</td></tr>
<tr><td><code>ask_perplexity</code></td><td>Query Perplexity (supports file upload)</td></tr>
<tr><td><code>ask_scoped_chat</code></td><td>Query an existing ChatGPT Project, Claude Project, Perplexity Space, or Gemini Gem using the logged-in browser session</td></tr>
<tr><td><code>ask_all_ais</code></td><td>Send same query to all providers at once</td></tr>
<tr><td><code>ask_selected</code></td><td>Pick specific providers to query</td></tr>
<tr><td><code>compare_ais</code></td><td>Get and compare responses side by side</td></tr>
<tr><td><code>smart_query</code></td><td>Auto-picks best provider, falls back if one fails</td></tr>
</table>

#### Scoped chat examples

Use <code>ask_scoped_chat</code> when you want a provider-specific workspace to apply its saved instructions, files, or knowledge. The tool requires an existing scope URL and sends through DOM mode so the logged-in browser page controls the workspace context.

```json
{
"provider": "chatgpt",
"scopeUrl": "https://chatgpt.com/g/g-p-example/project?tab=chats",
"message": "Use this project's instructions and summarize the attached packet.",
"files": ["/absolute/path/packet.zip"],
"newChat": true
}
```

```json
{
"provider": "claude",
"scopeUrl": "https://claude.ai/project/example",
"message": "Continue from this project's knowledge base and draft the implementation checklist.",
"newChat": false
}
```

```json
{
"provider": "perplexity",
"scopeUrl": "https://www.perplexity.ai/spaces/example",
"message": "Search this Space's sources and give me the relevant findings."
}
```

```json
{
"provider": "gemini",
"scopeUrl": "https://gemini.google.com/gems/example",
"message": "Use this Gem's instructions to review the uploaded notes.",
"files": ["/absolute/path/notes.pdf"]
}
```

### 🔧 Development Tools

<table>
Expand Down
281 changes: 281 additions & 0 deletions src/mcp-server-v3.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,149 @@ function buildMessageWithFiles(message, files) {
return message;
}

// ─── Scoped Chat Helpers ─────────────────────────────

const SCOPED_CHAT_PROVIDERS = {
chatgpt: {
label: 'ChatGPT Project',
host: 'chatgpt.com',
scopeSegments: ['g']
},
claude: {
label: 'Claude Project',
host: 'claude.ai',
scopeSegments: ['project', 'projects']
},
perplexity: {
label: 'Perplexity Space',
host: 'perplexity.ai',
scopeSegments: ['space', 'spaces']
},
gemini: {
label: 'Gemini Gem',
host: 'gemini.google.com',
scopeSegments: ['gem', 'gems']
}
};

function getScopedChatConfig(provider) {
const config = SCOPED_CHAT_PROVIDERS[provider];
if (!config) {
throw new Error(`Unsupported scoped chat provider: ${provider}`);
}
return config;
}

function hostMatches(actualHost, allowedHost) {
const host = actualHost.toLowerCase();
const allowed = allowedHost.toLowerCase();
return host === allowed || host.endsWith(`.${allowed}`);
}

function parseProviderScopedUrl(provider, rawUrl) {
const config = getScopedChatConfig(provider);
let url;

try {
url = new URL(rawUrl);
} catch {
throw new Error(`Invalid ${config.label} URL: ${rawUrl}`);
}

if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error(`${config.label} URL must use http or https`);
}
Comment thread
mhmdaskari marked this conversation as resolved.
Outdated

if (!hostMatches(url.hostname, config.host)) {
throw new Error(`${config.label} URL must be on ${config.host}`);
}

const details = scopedDetailsForUrl(provider, url);
if (!details.identity) {
throw new Error(`${config.label} URL must be a valid ${config.label.toLowerCase()} workspace URL`);
}
Comment thread
mhmdaskari marked this conversation as resolved.

return { url, landingUrl: details.landingUrl, identity: details.identity, config };
}

function scopedIdentityForUrl(provider, urlLike) {
return scopedDetailsForUrl(provider, urlLike).identity;
}

function scopedDetailsForUrl(provider, urlLike) {
let url;
try {
url = urlLike instanceof URL ? urlLike : new URL(urlLike);
} catch {
return { identity: '', landingUrl: null };
}

const config = SCOPED_CHAT_PROVIDERS[provider];
if (!config || !hostMatches(url.hostname, config.host)) {
return { identity: '', landingUrl: null };
}

const segments = url.pathname.split('/').filter(Boolean);
// Canonical segment so equivalent scope URLs (e.g. /project/<id> vs
// /projects/<id>) resolve to the same identity for newChat=false reuse.
const canonicalSegment = config.scopeSegments[0];
for (const scopeSegment of config.scopeSegments) {
const index = segments.indexOf(scopeSegment);
if (index >= 0 && segments[index + 1]) {
const scopeId = segments[index + 1];
const landingUrl = new URL(url.href);
landingUrl.hash = '';
landingUrl.search = '';

if (provider === 'chatgpt' && scopeSegment === 'g') {
landingUrl.pathname = `/g/${scopeId}/project`;
landingUrl.search = '?tab=chats';
} else {
landingUrl.pathname = `/${scopeSegment}/${scopeId}`;
}

return {
identity: `${provider}:${config.host}/${canonicalSegment}/${scopeId.toLowerCase()}`,
landingUrl
};
}
}

const gemId = url.searchParams.get('gem') || url.searchParams.get('gemId');
if (provider === 'gemini' && gemId) {
// Clear hash/query and use the canonical /gem/<id> path so the landing
// URL matches the segment-based branch instead of the raw input URL.
const landingUrl = new URL(url.href);
landingUrl.hash = '';
landingUrl.search = '';
landingUrl.pathname = `/gem/${gemId}`;
return {
identity: `${provider}:${config.host}/gem/${gemId.toLowerCase()}`,
landingUrl
};
}

return { identity: '', landingUrl: null };
}

function resolveUploadFiles(files) {
if (!files || files.length === 0) return [];

return files.map((filePath) => {
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) {
throw new Error(`File not found: ${filePath}`);
}

const stat = fs.statSync(resolved);
if (!stat.isFile()) {
throw new Error(`Not a file: ${filePath}`);
}

return resolved;
});
}
Comment thread
mhmdaskari marked this conversation as resolved.

// ─── AI Providers (IPC-backed) ─────────────────────

class AIProvider {
Expand Down Expand Up @@ -347,6 +490,73 @@ class AIProvider {
return result.response || 'No response received';
}

async _doScopedChat({ scopeUrl, message, files = [], newChat = true, uploadSettleMs = 3000 }) {
await this.ensureInitialized();

const scope = parseProviderScopedUrl(this.name, scopeUrl);
const uploadFiles = resolveUploadFiles(files);
let currentUrl = await this.currentUrl();
const currentIdentity = scopedIdentityForUrl(this.name, currentUrl);
const shouldNavigate = newChat || currentIdentity !== scope.identity;

if (shouldNavigate) {
console.error(`[${this.name}] Navigating to ${scope.config.label}: ${scope.landingUrl.href}`);
await this.ipc.send('navigate', this.name, { url: scope.landingUrl.href });
await this.sleep(1500);
Comment thread
mhmdaskari marked this conversation as resolved.
Outdated
currentUrl = await this.currentUrl();
const navigatedIdentity = scopedIdentityForUrl(this.name, currentUrl);
if (navigatedIdentity !== scope.identity) {
throw new Error(`Failed to open ${scope.config.label}. Expected ${scope.landingUrl.href} but landed on ${currentUrl}`);
}
console.error(`[${this.name}] Continuing current ${scope.config.label}`);
}
Comment thread
mhmdaskari marked this conversation as resolved.
Comment thread
mhmdaskari marked this conversation as resolved.

const uploaded = [];
const failedUploads = [];
for (const filePath of uploadFiles) {
console.error(`[${this.name}] Uploading scoped chat file: ${filePath}`);
const uploadResult = await this.ipc.send('uploadFile', this.name, { filePath });
if (uploadResult && uploadResult.success) {
uploaded.push({ filePath, result: uploadResult });
await this.sleep(Math.max(0, uploadSettleMs));
await this.ipc.send('waitForSendButton', this.name, {});
} else {
Comment thread
mhmdaskari marked this conversation as resolved.
const reason = (uploadResult && uploadResult.error) || 'upload failed';
console.error(`[${this.name}] Scoped chat file upload failed: ${filePath} — ${reason}`);
failedUploads.push({ filePath, error: reason });
}
Comment thread
mhmdaskari marked this conversation as resolved.
}

let typingCheck = await this.getTypingStatus();
let waitCount = 0;
while (typingCheck.isTyping && waitCount < 5) {
console.error(`[${this.name}] AI still typing, waiting before scoped chat send...`);
await this.sleep(1000);
Comment thread
mhmdaskari marked this conversation as resolved.
typingCheck = await this.getTypingStatus();
waitCount++;
}

console.error(`[${this.name}] Sending scoped chat message with DOM mode...`);
await this.ipc.send('sendMessage', this.name, { message, forceDOM: true });
Comment thread
mhmdaskari marked this conversation as resolved.

console.error(`[${this.name}] Waiting for scoped chat response...`);
const result = await this.ipc.send('getResponseWithTyping', this.name, {});
const response = result.response || 'No response received';
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

return {
scopedChat: true,
provider: this.name,
scope: scope.config.label,
scopeUrl: scope.landingUrl.href,
currentUrl: await this.currentUrl(),
newChat,
filesUploaded: uploaded.length,
filesFailed: failedUploads.length,
...(failedUploads.length ? { uploadErrors: failedUploads.map(f => `${f.filePath}: ${f.error}`) } : {}),
response
};
}

async chat(message, useCache = true) {
// Cache check runs OUTSIDE queue — instant return, no waiting
if (useCache && this.cache.has(message)) {
Expand Down Expand Up @@ -381,7 +591,27 @@ class AIProvider {
return responsePromise;
}

async scopedChat(options) {
this._queueLength++;
const position = this._queueLength;
if (position > 1) {
console.error(`[${this.name}] Scoped chat queued (position ${position}). Waiting for previous request...`);
}

const responsePromise = this._queue.then(async () => {
console.error(`[${this.name}] Processing scoped chat (${position} of ${this._queueLength})...`);
const response = await this._doScopedChat(options);
this._queueLength--;
return response;
}).catch((err) => {
this._queueLength--;
throw err;
});

this._queue = responsePromise.catch(() => {});

return responsePromise;
}

async search(query, useCache = true) {
return await this.chat(query, useCache);
Expand All @@ -392,6 +622,11 @@ class AIProvider {
return result;
}

async currentUrl() {
const result = await this.executeScript('window.location.href');
return result?.result || '';
}

async newConversation() {
await this.ipc.send('newConversation', this.name);
}
Expand Down Expand Up @@ -463,6 +698,8 @@ const chatgpt = new AIProvider('chatgpt', ipcClient);
const claude = new AIProvider('claude', ipcClient);
const gemini = new AIProvider('gemini', ipcClient);

const providerInstances = { perplexity, chatgpt, claude, gemini };

const router = new SmartRouter({ perplexity, chatgpt, claude, gemini });

// Create MCP Server
Expand Down Expand Up @@ -510,6 +747,21 @@ function formatResult(obj) {
return out.trim();
}

// ask_scoped_chat — { scopedChat, provider, scope, response }
if (obj.scopedChat && obj.response) {
let out = '';
out += `**Provider:** ${obj.provider}\n`;
out += `**Scope:** ${obj.scope}\n`;
if (obj.currentUrl) out += `**Current URL:** ${obj.currentUrl}\n`;
if (typeof obj.filesUploaded === 'number') out += `**Files uploaded:** ${obj.filesUploaded}\n`;
if (typeof obj.filesFailed === 'number') out += `**Files failed:** ${obj.filesFailed}\n`;
if (Array.isArray(obj.uploadErrors) && obj.uploadErrors.length) {
Comment thread
mhmdaskari marked this conversation as resolved.
out += `**Upload errors:**\n${obj.uploadErrors.map(e => `- ${e}`).join('\n')}\n`;
}
out += `\n${obj.response}`;
return out.trim();
}

// compare_ais / ask_all — { provider: response, ... } where values are strings
if (Object.values(obj).every(v => typeof v === 'string')) {
let out = '';
Expand Down Expand Up @@ -1311,6 +1563,35 @@ server.tool(
}
);

server.tool(
'ask_scoped_chat',
{
provider: z.enum(['chatgpt', 'claude', 'perplexity', 'gemini']).describe('Provider to use: chatgpt, claude, perplexity, or gemini'),
scopeUrl: z.string().describe('Existing project-like workspace URL: ChatGPT Project, Claude Project, Perplexity Space, or Gemini Gem'),
message: z.string().describe('Message to send inside the scoped chat using DOM mode'),
files: z.array(z.string()).optional().describe('Optional: local file paths to upload into the scoped chat before sending. These are uploaded, not pasted as text context.'),
newChat: z.boolean().optional().describe('Default true. When true, navigate to the scope URL before sending. When false, continue the current scoped chat if it matches the same scope.'),
uploadSettleMs: z.number().optional().describe('Milliseconds to wait after each upload before sending. Default: 3000.')
Comment thread
mhmdaskari marked this conversation as resolved.
Outdated
},
async ({ provider, scopeUrl, message, files, newChat = true, uploadSettleMs = 3000 }) => {
const disabled = checkDisabled(provider);
if (disabled) return disabled;

try {
const instance = providerInstances[provider];
return toolResponse(await instance.scopedChat({
scopeUrl,
message,
files,
newChat,
uploadSettleMs
}));
} catch (err) {
return toolError(err);
}
}
);

server.tool(
'new_conversation',
{},
Expand Down