innernet-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/LICENSE +21 -0
- package/README.md +49 -0
- package/dist/db.js +99 -0
- package/dist/index.js +123 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jordan Schuster
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# innernet-mcp
|
|
2
|
+
|
|
3
|
+
Read-only MCP server over your [Innernet](https://jointheinner.net/build-your-own) graph.
|
|
4
|
+
Ask your agent about your own network.
|
|
5
|
+
|
|
6
|
+
```json
|
|
7
|
+
{
|
|
8
|
+
"mcpServers": {
|
|
9
|
+
"innernet": {
|
|
10
|
+
"command": "npx",
|
|
11
|
+
"args": ["-y", "innernet-mcp"],
|
|
12
|
+
"env": { "INNERNET_DB_URL": "postgres://user:pass@localhost:5432/innernet" }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or let the CLI write it for you:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm innernet mcp --install
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Tools
|
|
25
|
+
|
|
26
|
+
| Tool | What it answers |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `search_people` | Find someone by name, email, company, title, or handle |
|
|
29
|
+
| `get_person` | The full dossier for one person — every fact with the source it came from |
|
|
30
|
+
| `recent_contact` | Who you have actually been in touch with lately |
|
|
31
|
+
| `gone_quiet` | Real history, no recent contact. The reconnect list |
|
|
32
|
+
|
|
33
|
+
## Read-only, by construction
|
|
34
|
+
|
|
35
|
+
Every tool is a `SELECT`. The server has no write path at all — an agent that can rewrite
|
|
36
|
+
your relationship history is a much worse trade than one that can only read it.
|
|
37
|
+
|
|
38
|
+
Point `INNERNET_DB_URL` at a read-only Postgres role in any setting you care about. The
|
|
39
|
+
server never needs write access, so granting it is pure downside.
|
|
40
|
+
|
|
41
|
+
## Notes
|
|
42
|
+
|
|
43
|
+
- Requires a database built by [`innernet-cli`](https://www.npmjs.com/package/innernet-cli).
|
|
44
|
+
- Facts carry a `source_url` and a confidence value. Treat low confidence as a lead, not a
|
|
45
|
+
fact — a name match is not a person.
|
|
46
|
+
- A missing `INNERNET_DB_URL` is returned to the agent as an error message rather than
|
|
47
|
+
thrown, so the model can explain what is wrong instead of the call dying.
|
|
48
|
+
|
|
49
|
+
MIT
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Postgres access for the MCP server.
|
|
2
|
+
//
|
|
3
|
+
// Read-only by construction: every statement here is a SELECT, and the
|
|
4
|
+
// connection is opened against whatever role INNERNET_DB_URL carries. Point it
|
|
5
|
+
// at a read-only role in production — the server never needs write access, so
|
|
6
|
+
// granting it is pure downside.
|
|
7
|
+
import postgres from "postgres";
|
|
8
|
+
let _sql = null;
|
|
9
|
+
export function db() {
|
|
10
|
+
if (_sql)
|
|
11
|
+
return _sql;
|
|
12
|
+
const url = process.env.INNERNET_DB_URL;
|
|
13
|
+
if (!url) {
|
|
14
|
+
throw new Error("INNERNET_DB_URL is not set. Point it at your Innernet Postgres, e.g. " +
|
|
15
|
+
"postgres://user:pass@localhost:5432/postgres");
|
|
16
|
+
}
|
|
17
|
+
_sql = postgres(url, { max: 4, prepare: false, idle_timeout: 20 });
|
|
18
|
+
return _sql;
|
|
19
|
+
}
|
|
20
|
+
const PERSON_COLS = `id, display_name, primary_email, title, company,
|
|
21
|
+
linkedin_url, github_handle, twitter_handle`;
|
|
22
|
+
/**
|
|
23
|
+
* Substring search across the fields a human actually remembers someone by.
|
|
24
|
+
* Deliberately not vector search: an MCP client asks concrete questions, and a
|
|
25
|
+
* lexical match is explainable in a way a cosine score is not.
|
|
26
|
+
*/
|
|
27
|
+
export async function searchPeople(q, limit) {
|
|
28
|
+
const sql = db();
|
|
29
|
+
const like = `%${q}%`;
|
|
30
|
+
return (await sql.unsafe(`select ${PERSON_COLS}
|
|
31
|
+
from people
|
|
32
|
+
where display_name ilike $1
|
|
33
|
+
or primary_email ilike $1
|
|
34
|
+
or company ilike $1
|
|
35
|
+
or title ilike $1
|
|
36
|
+
or github_handle ilike $1
|
|
37
|
+
or twitter_handle ilike $1
|
|
38
|
+
order by display_name asc
|
|
39
|
+
limit $2`, [like, limit]));
|
|
40
|
+
}
|
|
41
|
+
export async function getPerson(id) {
|
|
42
|
+
const sql = db();
|
|
43
|
+
const rows = (await sql.unsafe(`select ${PERSON_COLS} from people where id = $1 limit 1`, [id]));
|
|
44
|
+
return rows[0] ?? null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Facts about a person, best-confidence first.
|
|
48
|
+
*
|
|
49
|
+
* `to_regclass` guards the whole call: person_facets only exists once the
|
|
50
|
+
* enrichment migrations have run, and a fresh install should get an empty
|
|
51
|
+
* dossier rather than a stack trace about a missing relation.
|
|
52
|
+
*/
|
|
53
|
+
export async function getFacets(personId, limit = 200) {
|
|
54
|
+
const sql = db();
|
|
55
|
+
const exists = (await sql.unsafe(`select to_regclass('public.person_facets') is not null as ok`));
|
|
56
|
+
if (!exists[0]?.ok)
|
|
57
|
+
return [];
|
|
58
|
+
return (await sql.unsafe(`select facet_key, facet_value, source_url, confidence
|
|
59
|
+
from person_facets
|
|
60
|
+
where person_id = $1
|
|
61
|
+
order by confidence desc, facet_key asc
|
|
62
|
+
limit $2`, [personId, limit]));
|
|
63
|
+
}
|
|
64
|
+
/** Who you actually talk to, ranked by volume, within a lookback window. */
|
|
65
|
+
export async function recentContact(days, limit) {
|
|
66
|
+
const sql = db();
|
|
67
|
+
return (await sql.unsafe(`select p.id as person_id,
|
|
68
|
+
p.display_name as display_name,
|
|
69
|
+
count(i.id)::int as interactions,
|
|
70
|
+
max(i.occurred_at)::text as last_seen
|
|
71
|
+
from people p
|
|
72
|
+
join interactions i
|
|
73
|
+
on i.from_person_id = p.id
|
|
74
|
+
or p.id = any(i.to_person_ids)
|
|
75
|
+
where i.occurred_at > now() - ($1 || ' days')::interval
|
|
76
|
+
group by p.id, p.display_name
|
|
77
|
+
order by interactions desc
|
|
78
|
+
limit $2`, [String(days), limit]));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* People you have gone quiet on: a real history, nothing recent.
|
|
82
|
+
* This is the query the graph exists to answer, so it gets a first-class tool.
|
|
83
|
+
*/
|
|
84
|
+
export async function goneQuiet(days, limit) {
|
|
85
|
+
const sql = db();
|
|
86
|
+
return (await sql.unsafe(`select p.id as person_id,
|
|
87
|
+
p.display_name as display_name,
|
|
88
|
+
count(i.id)::int as interactions,
|
|
89
|
+
max(i.occurred_at)::text as last_seen
|
|
90
|
+
from people p
|
|
91
|
+
join interactions i
|
|
92
|
+
on i.from_person_id = p.id
|
|
93
|
+
or p.id = any(i.to_person_ids)
|
|
94
|
+
group by p.id, p.display_name
|
|
95
|
+
having max(i.occurred_at) < now() - ($1 || ' days')::interval
|
|
96
|
+
and count(i.id) >= 3
|
|
97
|
+
order by count(i.id) desc
|
|
98
|
+
limit $2`, [String(days), limit]));
|
|
99
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// innernet-mcp — read-only MCP server over your Innernet graph.
|
|
3
|
+
//
|
|
4
|
+
// Every tool is a SELECT. The server cannot mutate your graph, by design: an
|
|
5
|
+
// agent that can rewrite your relationship history is a much worse trade than
|
|
6
|
+
// one that can only read it.
|
|
7
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
8
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { getFacets, getPerson, goneQuiet, recentContact, searchPeople, } from "./db.js";
|
|
11
|
+
const server = new Server({ name: "innernet", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
12
|
+
const TOOLS = [
|
|
13
|
+
{
|
|
14
|
+
name: "search_people",
|
|
15
|
+
description: "Search your Innernet graph for people by name, email, company, title, or social handle. " +
|
|
16
|
+
"Returns matching people with their ids — pass an id to get_person for the full dossier.",
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
query: { type: "string", description: "Name, company, title, email, or handle fragment." },
|
|
21
|
+
limit: { type: "number", description: "Max results (default 20, max 100)." },
|
|
22
|
+
},
|
|
23
|
+
required: ["query"],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "get_person",
|
|
28
|
+
description: "Get the full dossier for one person by id: their profile plus every enriched fact, " +
|
|
29
|
+
"each with the source URL it came from and a confidence score.",
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
person_id: { type: "string", description: "The person's uuid, from search_people." },
|
|
34
|
+
},
|
|
35
|
+
required: ["person_id"],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "recent_contact",
|
|
40
|
+
description: "Who you have actually been in touch with recently, ranked by interaction volume. " +
|
|
41
|
+
"Use this to answer 'who am I close to right now'.",
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
days: { type: "number", description: "Lookback window in days (default 90)." },
|
|
46
|
+
limit: { type: "number", description: "Max results (default 20, max 100)." },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "gone_quiet",
|
|
52
|
+
description: "People you have a real history with but have not spoken to lately — the reconnect list. " +
|
|
53
|
+
"Answers 'who am I letting drift'.",
|
|
54
|
+
inputSchema: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
days: { type: "number", description: "Silence threshold in days (default 180)." },
|
|
58
|
+
limit: { type: "number", description: "Max results (default 20, max 100)." },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
64
|
+
/** Clamp a caller-supplied limit; an agent will happily ask for 10000. */
|
|
65
|
+
function clampLimit(v, dflt) {
|
|
66
|
+
const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : dflt;
|
|
67
|
+
return Math.min(Math.max(n, 1), 100);
|
|
68
|
+
}
|
|
69
|
+
function clampDays(v, dflt) {
|
|
70
|
+
const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : dflt;
|
|
71
|
+
return Math.min(Math.max(n, 1), 3650);
|
|
72
|
+
}
|
|
73
|
+
function ok(data) {
|
|
74
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
75
|
+
}
|
|
76
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
77
|
+
const { name } = req.params;
|
|
78
|
+
const args = (req.params.arguments ?? {});
|
|
79
|
+
try {
|
|
80
|
+
switch (name) {
|
|
81
|
+
case "search_people": {
|
|
82
|
+
const q = String(args.query ?? "").trim();
|
|
83
|
+
if (!q)
|
|
84
|
+
return ok({ error: "query is required and cannot be empty" });
|
|
85
|
+
const people = await searchPeople(q, clampLimit(args.limit, 20));
|
|
86
|
+
return ok({ count: people.length, people });
|
|
87
|
+
}
|
|
88
|
+
case "get_person": {
|
|
89
|
+
const id = String(args.person_id ?? "").trim();
|
|
90
|
+
if (!id)
|
|
91
|
+
return ok({ error: "person_id is required" });
|
|
92
|
+
const person = await getPerson(id);
|
|
93
|
+
if (!person)
|
|
94
|
+
return ok({ error: `no person with id ${id}` });
|
|
95
|
+
const facets = await getFacets(id);
|
|
96
|
+
return ok({
|
|
97
|
+
person,
|
|
98
|
+
facet_count: facets.length,
|
|
99
|
+
facets,
|
|
100
|
+
note: facets.length === 0
|
|
101
|
+
? "No enriched facts yet — run `innernet enrich` to populate this dossier."
|
|
102
|
+
: "Every fact carries the source_url it came from. Treat a low confidence value as a lead, not a fact.",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
case "recent_contact":
|
|
106
|
+
return ok({ people: await recentContact(clampDays(args.days, 90), clampLimit(args.limit, 20)) });
|
|
107
|
+
case "gone_quiet":
|
|
108
|
+
return ok({ people: await goneQuiet(clampDays(args.days, 180), clampLimit(args.limit, 20)) });
|
|
109
|
+
default:
|
|
110
|
+
return ok({ error: `unknown tool: ${name}` });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
// Surface the failure to the agent as data rather than throwing: a thrown
|
|
115
|
+
// error ends the tool call, while a returned one lets the model explain
|
|
116
|
+
// what went wrong (usually a missing INNERNET_DB_URL).
|
|
117
|
+
return ok({ error: e.message });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
const transport = new StdioServerTransport();
|
|
121
|
+
await server.connect(transport);
|
|
122
|
+
// stderr only — stdout is the MCP wire protocol and must stay clean.
|
|
123
|
+
console.error("innernet mcp: ready");
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "innernet-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Read-only MCP server over your Innernet graph — search people, pull a dossier, find your warmest intro path.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"innernet-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
18
|
+
"postgres": "^3.4.5",
|
|
19
|
+
"zod": "^3.23.8"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^22.0.0",
|
|
23
|
+
"typescript": "^5.6.0"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"author": "Jordan Schuster",
|
|
29
|
+
"homepage": "https://jointheinner.net/build-your-own",
|
|
30
|
+
"keywords": [
|
|
31
|
+
"mcp",
|
|
32
|
+
"model-context-protocol",
|
|
33
|
+
"contacts",
|
|
34
|
+
"crm",
|
|
35
|
+
"personal-graph",
|
|
36
|
+
"innernet"
|
|
37
|
+
],
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/nayslayer143/innernet.git",
|
|
41
|
+
"directory": "packages/mcp"
|
|
42
|
+
},
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/nayslayer143/innernet/issues"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc -p tsconfig.json",
|
|
51
|
+
"start": "node dist/index.js",
|
|
52
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
53
|
+
}
|
|
54
|
+
}
|