@tiongcreative/assistant-mcp 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,115 @@
1
+ // Finding a running app and calling its local API.
2
+ //
3
+ // Each app that allows assistant access writes <data root>/<app>/local_data/assistant.json
4
+ // while it is open: { app, port, pid, version, token, startedAt }. The file is readable
5
+ // only by the user account that runs the app, which is also the account running this
6
+ // server. The token is sent as a Bearer header. Nothing here listens on the network;
7
+ // every call goes to 127.0.0.1.
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ const HOST = '127.0.0.1';
13
+
14
+ // Where the apps keep their data. TC_DATA_ROOT is for tests and the standalone servers.
15
+ export function dataRoot() {
16
+ return process.env.TC_DATA_ROOT || path.join(os.homedir(), 'Library', 'Application Support');
17
+ }
18
+
19
+ // Most apps keep their files in a local_data folder under their data dir; an app that
20
+ // writes to the data dir itself says so with tokenFile.
21
+ function tokenFile(app) {
22
+ return path.join(
23
+ dataRoot(),
24
+ app.dataDir,
25
+ app.tokenFile ?? path.join('local_data', 'assistant.json'),
26
+ );
27
+ }
28
+
29
+ /**
30
+ * Whether the app exists on this Mac: its bundle in Applications, or a data folder it has
31
+ * written. Tools are only offered for installed apps, so an assistant never tells the user
32
+ * to turn on a switch in an app they do not have.
33
+ */
34
+ export function isInstalled(app) {
35
+ if (process.env.TC_DATA_ROOT) return fs.existsSync(path.join(dataRoot(), app.dataDir));
36
+ const bundles = [
37
+ path.join('/Applications', `${app.bundleName}.app`),
38
+ path.join(os.homedir(), 'Applications', `${app.bundleName}.app`),
39
+ ];
40
+ return bundles.some((b) => fs.existsSync(b)) || fs.existsSync(path.join(dataRoot(), app.dataDir));
41
+ }
42
+
43
+ function processAlive(pid) {
44
+ try {
45
+ process.kill(pid, 0);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Reads the app's token file and checks the app answers. Returns
54
+ * { ok: true, baseUrl, token, version } or { ok: false, reason }.
55
+ */
56
+ export async function appConnection(app) {
57
+ let record;
58
+ try {
59
+ record = JSON.parse(fs.readFileSync(tokenFile(app), 'utf8'));
60
+ } catch (err) {
61
+ if (err.code === 'ENOENT') {
62
+ return { ok: false, reason: isInstalled(app) ? 'not-enabled' : 'not-installed' };
63
+ }
64
+ return { ok: false, reason: 'unreadable', detail: err.message };
65
+ }
66
+ if (!record || typeof record.port !== 'number' || typeof record.token !== 'string') {
67
+ return { ok: false, reason: 'unreadable', detail: 'token file has an unexpected shape' };
68
+ }
69
+ if (record.pid && !processAlive(record.pid)) return { ok: false, reason: 'not-running' };
70
+ const baseUrl = `http://${HOST}:${record.port}`;
71
+ try {
72
+ const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(2000) });
73
+ if (!res.ok) return { ok: false, reason: 'not-running' };
74
+ } catch {
75
+ return { ok: false, reason: 'not-running' };
76
+ }
77
+ return { ok: true, baseUrl, token: record.token, version: record.version ?? null };
78
+ }
79
+
80
+ export class ApiError extends Error {
81
+ constructor(message, status, problems = []) {
82
+ super(message);
83
+ this.status = status;
84
+ this.problems = problems;
85
+ }
86
+ }
87
+
88
+ export async function apiCall(conn, method, urlPath, body) {
89
+ const res = await fetch(conn.baseUrl + urlPath, {
90
+ method,
91
+ headers: {
92
+ authorization: `Bearer ${conn.token}`,
93
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
94
+ },
95
+ body: body === undefined ? undefined : JSON.stringify(body),
96
+ signal: AbortSignal.timeout(10000),
97
+ });
98
+ const text = await res.text();
99
+ const data = text ? JSON.parse(text) : null;
100
+ if (!res.ok) {
101
+ throw new ApiError(data?.error ?? `Request failed (${res.status})`, res.status, data?.problems);
102
+ }
103
+ return data;
104
+ }
105
+
106
+ /** The result a tool returns when the app cannot be reached. */
107
+ export function notAvailable(app, conn) {
108
+ const reasons = {
109
+ 'not-enabled': `${app.name} is installed but not sharing with assistants. In ${app.name}, open Settings and turn on "Allow assistant access".`,
110
+ 'not-installed': `${app.name} is not installed on this Mac.`,
111
+ 'not-running': `${app.name} is not open. Ask the user whether to open it, then call open_app with app "${app.id}".`,
112
+ unreadable: `${app.name}'s assistant file could not be read: ${conn.detail ?? 'unknown error'}.`,
113
+ };
114
+ return { app: app.name, available: false, reason: conn.reason, message: reasons[conn.reason] };
115
+ }
package/src/dates.js ADDED
@@ -0,0 +1,18 @@
1
+ // Date strings are YYYY-MM-DD in the user's local calendar, as the apps store them.
2
+
3
+ export function todayLocal(now = new Date()) {
4
+ const y = now.getFullYear();
5
+ const m = String(now.getMonth() + 1).padStart(2, '0');
6
+ const d = String(now.getDate()).padStart(2, '0');
7
+ return `${y}-${m}-${d}`;
8
+ }
9
+
10
+ function toUtcDay(dateString) {
11
+ const [y, m, d] = dateString.split('-').map(Number);
12
+ return Date.UTC(y, m - 1, d);
13
+ }
14
+
15
+ /** Whole days from `from` to `to`; negative when `to` is earlier. */
16
+ export function daysBetween(from, to) {
17
+ return Math.round((toUtcDay(to) - toUtcDay(from)) / 86400000);
18
+ }
package/src/index.js ADDED
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // Tiong Creative assistant: an MCP server over stdio that lets the assistant running it
3
+ // (Claude Desktop, Claude Code) use the Tiong Creative apps on this Mac.
4
+ //
5
+ // It opens no network listener. Each app is reached on 127.0.0.1 with a token the app
6
+ // writes only while its "Allow assistant access" setting is on (src/connection.js).
7
+ // Tools that change data say so in their description and return what they changed.
8
+ import { execFile } from 'node:child_process';
9
+ import { createRequire } from 'node:module';
10
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import { z } from 'zod';
13
+ import { ApiError, isInstalled } from './connection.js';
14
+ import * as dueAgain from './apps/due-again.js';
15
+ import * as giftKeeper from './apps/gift-keeper.js';
16
+ import * as clientManager from './apps/client-manager.js';
17
+ import * as listsAndNotes from './apps/lists-and-notes.js';
18
+
19
+ const { version } = createRequire(import.meta.url)('../package.json');
20
+
21
+ // One entry per app. Add an app by adding a module with the same shape.
22
+ const ALL_APPS = [dueAgain, giftKeeper, clientManager, listsAndNotes];
23
+ // Only apps present on this Mac get tools, so the assistant never points the user at an
24
+ // app they do not have. Checked once at start-up: install an app, restart the assistant.
25
+ const APPS = ALL_APPS.filter((a) => isInstalled(a.app));
26
+ const MISSING = ALL_APPS.filter((a) => !isInstalled(a.app)).map((a) => a.app.name);
27
+
28
+ function text(value) {
29
+ return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
30
+ }
31
+
32
+ function failure(err) {
33
+ const message =
34
+ err instanceof ApiError
35
+ ? `${err.message}${err.problems?.length ? ': ' + err.problems.join('; ') : ''}`
36
+ : err?.message || String(err);
37
+ return { isError: true, content: [{ type: 'text', text: message }] };
38
+ }
39
+
40
+ const server = new McpServer({ name: 'tiongcreative-assistant', version });
41
+
42
+ server.registerTool(
43
+ 'whats_on',
44
+ {
45
+ title: "What's on",
46
+ description:
47
+ 'Everything coming up across the Tiong Creative apps on this Mac within a number of days: overdue and due items, with the app each one belongs to. Start here for "what needs my attention".',
48
+ inputSchema: {
49
+ days: z.number().int().min(0).max(365).default(14).describe('How far ahead to look'),
50
+ },
51
+ },
52
+ async ({ days }) => {
53
+ const sections = await Promise.all(APPS.map((a) => a.attention(days)));
54
+ const items = sections
55
+ .flatMap((s) => s.items)
56
+ .sort((a, b) => (a.daysUntilDue ?? 0) - (b.daysUntilDue ?? 0));
57
+ const unavailable = sections
58
+ .filter((s) => !s.available)
59
+ .map((s) => ({ app: s.app, reason: s.reason }));
60
+ return text({
61
+ days,
62
+ count: items.length,
63
+ items,
64
+ unavailable,
65
+ ...(APPS.length === 0
66
+ ? {
67
+ note: `No Tiong Creative apps are installed on this Mac. The assistant works with: ${MISSING.join(', ')}.`,
68
+ }
69
+ : {}),
70
+ });
71
+ },
72
+ );
73
+
74
+ server.registerTool(
75
+ 'open_app',
76
+ {
77
+ title: 'Open an app',
78
+ description:
79
+ 'Opens one of the Tiong Creative apps on this Mac so its tools can be used. Only call this after the user has agreed to open it.',
80
+ inputSchema: {
81
+ app: z.enum(APPS.length ? APPS.map((a) => a.app.id) : ['none']),
82
+ },
83
+ },
84
+ async ({ app }) => {
85
+ const entry = APPS.find((a) => a.app.id === app);
86
+ if (!entry) return failure(new Error('That app is not installed on this Mac.'));
87
+ await new Promise((resolve, reject) => {
88
+ execFile('open', ['-a', entry.app.bundleName], (err) => (err ? reject(err) : resolve()));
89
+ });
90
+ return text({ app: entry.app.name, opened: true, note: 'Give it a few seconds to start.' });
91
+ },
92
+ );
93
+
94
+ for (const { tools } of APPS) {
95
+ for (const tool of tools) {
96
+ server.registerTool(
97
+ tool.name,
98
+ {
99
+ description: tool.description,
100
+ inputSchema: tool.input.shape,
101
+ annotations: tool.write
102
+ ? { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
103
+ : { readOnlyHint: true },
104
+ },
105
+ async (input) => {
106
+ try {
107
+ return text(await tool.run(tool.input.parse(input)));
108
+ } catch (err) {
109
+ return failure(err);
110
+ }
111
+ },
112
+ );
113
+ }
114
+ }
115
+
116
+ await server.connect(new StdioServerTransport());