Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions hub-config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ sources:
# clientId: "your-client-id.apps.googleusercontent.com"
# clientSecret: "your-client-secret"

google_drive:
enabled: true
owner_auth:
type: oauth2
# Uncomment to use your own OAuth app:
# clientId: "your-client-id.apps.googleusercontent.com"
# clientSecret: "your-client-secret"

github:
enabled: true
owner_auth:
Expand Down
40 changes: 40 additions & 0 deletions packages/personaldatahub/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,46 @@ Search GitHub pull requests. Data is filtered according to the owner's access co
- `query` (optional) — Search query for pull requests
- `limit` (optional) — Maximum number of results

### list_drive_files
*(Google Drive — requires connected Google Drive OAuth)*

List files from Google Drive. Data is filtered according to the owner's access control policy.

**Parameters:**
- `purpose` (required) — Why this data is needed (logged for audit)
- `query` (optional) — Search query for file names or descriptions
- `limit` (optional) — Maximum number of results

### get_drive_file_content
*(Google Drive — requires connected Google Drive OAuth)*

Get the text content of a file from Google Drive.

**Parameters:**
- `fileId` (required) — The ID of the file to retrieve
- `purpose` (required) — Why this data is needed (logged for audit)

### create_drive_file
*(Google Drive — requires connected Google Drive OAuth)*

Propose creating a new file on Google Drive. The action is staged for the data owner to review — it does NOT execute until approved.

**Parameters:**
- `name` (required) — File name
- `description` (optional) — File description
- `mimeType` (optional) — MIME type (defaults to text/plain)
- `content` (optional) — File content
- `purpose` (required) — Why this action is being proposed (logged for audit)

### delete_drive_file
*(Google Drive — requires connected Google Drive OAuth)*

Propose deleting a file from Google Drive. The action is staged for the data owner to review — it does NOT execute until approved.

**Parameters:**
- `fileId` (required) — The ID of the file to delete
- `purpose` (required) — Why this action is being proposed (logged for audit)

## Direct API Fallback

If the MCP tools above are not available, you can call the PersonalDataHub API directly via HTTP.
Expand Down
83 changes: 83 additions & 0 deletions skills/drive-assistant/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
name: drive-assistant
description: Manage your Google Drive files through PersonalDataHub
user_invocable: true
---

# Drive Assistant

Help users manage their Google Drive files by listing recent documents, searching for files, and retrieving content.

## Instructions

### 1. Read the PersonalDataHub config

Read `~/.pdh/config.json` to get the `hubUrl`. If the file doesn't exist, tell the user to run `npx pdh init` and `npx pdh start` first.

### 2. Verify the hub is running

Run `curl -s <hubUrl>/health` via Bash. If it fails, tell the user to start the server with `npx pdh start`.

### 3. Parse the user's request

Analyze the user's message to identify:
- **Intent** — list files, search for a document, get file content, create a new file, or delete one.
- **Search terms** — specific file names or keywords.
- **File details** — name, mimeType, or content for creation/updates.

### 4. Execute the searches (Pull)

Pull file metadata from PersonalDataHub:

```bash
curl -s -X POST <hubUrl>/app/v1/pull \
-H "Content-Type: application/json" \
-d '{"source": "google_drive", "query": "<optional_search_term>", "limit": 20, "purpose": "Searching for <context>"}'
```

**Guidelines:**
- Use the `query` field to filter by file name or description.
- List recent files if no specific query is provided.

### 5. Analyze and synthesize

Review the retrieved files:
- Identify the file(s) the user is interested in.
- Extract file IDs for further actions (retrieval, update, deletion).

Present a summary of the found files to the user.

### 6. Execute Actions

File modifications and content retrieval require specific actions.

#### Get File Content:
```bash
curl -s -X POST <hubUrl>/app/v1/action \
-H "Content-Type: application/json" \
-d '{"source": "google_drive", "action_type": "get_file_content", "action_data": {"fileId": "<id>"}, "purpose": "Reading content of <filename>"}'
```

#### Create a File (Stages for approval):
```bash
curl -s -X POST <hubUrl>/app/v1/propose \
-H "Content-Type: application/json" \
-d '{"source": "google_drive", "action_type": "create_file", "action_data": {"name": "<filename>", "description": "<description>", "mimeType": "text/plain", "content": "<file_content>"}, "purpose": "Creating <filename> as requested by user"}'
```

#### Delete a File (Stages for approval):
```bash
curl -s -X POST <hubUrl>/app/v1/propose \
-H "Content-Type: application/json" \
-d '{"source": "google_drive", "action_type": "delete_file", "action_data": {"fileId": "<id>"}, "purpose": "Deleting <filename> as requested by user"}'
```

### 7. Finalize

If an action was proposed, tell the user it's waiting for their approval in the PersonalDataHub GUI at `<hubUrl>`.

## Important notes

- **All data goes through PersonalDataHub's access control.** You will only see files the owner has authorized.
- **Modifications require owner approval.** The `propose` endpoint stages the change.
- **Always provide a clear `purpose`.** Every API call is audited.
115 changes: 115 additions & 0 deletions src/ai/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,111 @@ function registerGitHubTools(server: McpServer, hubUrl: string): void {
);
}

function registerDriveTools(server: McpServer, hubUrl: string): void {
server.tool(
'list_drive_files',
'List files from Google Drive. Data is filtered according to the owner\'s access control policy.',
{
query: z.string().optional().describe('Search query for file names or descriptions'),
limit: z.number().optional().describe('Maximum number of results'),
purpose: z.string().describe('Why this data is needed (logged for audit)'),
},
{ readOnlyHint: true, destructiveHint: false },
async ({ query, limit, purpose }) => {
const body: Record<string, unknown> = { source: 'google_drive', purpose };
if (query) body.query = query;
if (limit) body.limit = limit;

const res = await fetch(`${hubUrl}/app/v1/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});

const json = await res.json();
return { content: [{ type: 'text' as const, text: JSON.stringify(json, null, 2) }] };
},
);

server.tool(
'get_drive_file_content',
'Get the text content of a file from Google Drive.',
{
fileId: z.string().describe('The ID of the file to retrieve'),
purpose: z.string().describe('Why this data is needed (logged for audit)'),
},
{ readOnlyHint: true, destructiveHint: false },
async ({ fileId, purpose }) => {
const res = await fetch(`${hubUrl}/app/v1/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: 'google_drive',
action_type: 'get_file_content',
action_data: { fileId },
purpose,
}),
});

const json = await res.json();
return { content: [{ type: 'text' as const, text: JSON.stringify(json, null, 2) }] };
},
);

server.tool(
'create_drive_file',
'Propose creating a new file on Google Drive. Staged for owner review.',
{
name: z.string().describe('File name'),
description: z.string().optional().describe('File description'),
mimeType: z.string().optional().describe('MIME type (defaults to text/plain)'),
content: z.string().optional().describe('File content'),
purpose: z.string().describe('Why this action is being proposed (logged for audit)'),
},
{ readOnlyHint: false, destructiveHint: false },
async ({ name, description, mimeType, content, purpose }) => {
const res = await fetch(`${hubUrl}/app/v1/propose`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: 'google_drive',
action_type: 'create_file',
action_data: { name, description, mimeType: mimeType || 'text/plain', content },
purpose,
}),
});

const json = await res.json();
return { content: [{ type: 'text' as const, text: JSON.stringify(json, null, 2) }] };
},
);

server.tool(
'delete_drive_file',
'Propose deleting a file from Google Drive. Staged for owner review.',
{
fileId: z.string().describe('The ID of the file to delete'),
purpose: z.string().describe('Why this action is being proposed (logged for audit)'),
},
{ readOnlyHint: false, destructiveHint: true },
async ({ fileId, purpose }) => {
const res = await fetch(`${hubUrl}/app/v1/propose`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: 'google_drive',
action_type: 'delete_file',
action_data: { fileId },
purpose,
}),
});

const json = await res.json();
return { content: [{ type: 'text' as const, text: JSON.stringify(json, null, 2) }] };
},
);
}

function registerCalendarTools(server: McpServer, hubUrl: string): void {
server.tool(
'read_calendar_events',
Expand Down Expand Up @@ -350,6 +455,16 @@ export async function startMcpServer(): Promise<McpServer> {
);
}

if (sources.google_drive?.connected) {
registerDriveTools(server, hubUrl);
registeredTools.push(
'list_drive_files',
'get_drive_file_content',
'create_drive_file',
'delete_drive_file',
);
}

if (sources.github?.connected) {
registerGitHubTools(server, hubUrl);
registeredTools.push('search_github_issues', 'search_github_prs');
Expand Down
32 changes: 32 additions & 0 deletions src/gateway/app-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,38 @@ export function createAppApi(deps: AppApiDeps): Hono {
return c.json(responsePayload);
});

// POST /action — execute an action immediately (limited to read-only actions like get_file_content)
app.post('/action', async (c) => {
const body = await c.req.json();
const { source, action_type, action_data, purpose } = body;
const actionPayload = action_data ?? body.params;

if (!purpose || !source || !action_type) {
return c.json({ ok: false, error: { code: 'BAD_REQUEST', message: 'Missing required fields: source, action_type, purpose' } }, 400);
}

const connector = deps.connectorRegistry.get(source);
if (!connector) {
return c.json({ ok: false, error: { code: 'NOT_FOUND', message: `Unknown source: "${source}"` } }, 404);
}

// Security: Only allow specific read-only actions to execute immediately.
// Mutations must go through /propose for owner review.
const immediateActions = ['get_file_content'];
if (!immediateActions.includes(action_type)) {
return c.json({ ok: false, error: { code: 'FORBIDDEN', message: `Action "${action_type}" must be proposed via /propose for owner review.` } }, 403);
}

try {
const result = await connector.executeAction(action_type, actionPayload);
const actionId = `act_imm_${randomUUID().slice(0, 8)}`;
await auditLog.logActionCommitted(actionId, source, result.success ? 'success' : 'failure');
return c.json({ ok: true, ...result });
} catch (err) {
return c.json({ ok: false, error: { code: 'INTERNAL_ERROR', message: String(err) } }, 500);
}
});

// POST /propose
app.post('/propose', async (c) => {
const body = await c.req.json();
Expand Down
Loading