notex-companion 0.3.0 → 0.3.2

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 CHANGED
@@ -2,8 +2,7 @@
2
2
 
3
3
  A local retrieval companion for [Notex](https://github.com/bhirmbani/notex). It reads a checkout's
4
4
  `graphify-out/graph.json` and serves deterministic search/query/path/node lookups over it — to the
5
- Notex browser page over loopback HTTP, and (once [TBR-69](https://linear.app/bmbn/issue/TBR-69)
6
- lands) to an MCP host over stdio.
5
+ Notex browser page over loopback HTTP, and to an MCP host over stdio.
7
6
 
8
7
  ## What it reads
9
8
 
@@ -42,10 +41,15 @@ of starting a broken server.
42
41
 
43
42
  ```bash
44
43
  notex-companion [serve] [options] # start the loopback HTTP server — the default command
45
- notex-companion mcp # start the stdio MCP server (stub until TBR-69 the bin
46
- # wiring ships here, the tool surface does not yet)
44
+ notex-companion mcp # start the stdio MCP server — graph_status, graph_search,
45
+ # graph_query, graph_path, graph_node, plus notex_list_questions,
46
+ # notex_get_question, notex_get_answer, notex_save_answer
47
+ # (require .notex/notex.json — see docs/specs/notex-mcp-server.md)
47
48
  ```
48
49
 
50
+ Setup, how it picks which checkout to serve, and a manual verification walkthrough:
51
+ [`docs/testing/mcp-server-setup.md`](https://github.com/bhirmbani/notex/blob/main/docs/testing/mcp-server-setup.md).
52
+
49
53
  ### `serve` options
50
54
 
51
55
  | Flag | Default | Meaning |
@@ -63,7 +67,7 @@ browser's `localStorage`, keyed by Repository, and it is never sent to the Notex
63
67
 
64
68
  ## `apiVersion`
65
69
 
66
- `ping` and every op response report `apiVersion` (currently `0.1.0`). Compatibility rule — see
70
+ `ping` and every op response report `apiVersion` (currently `0.2.1`). Compatibility rule — see
67
71
  [`companion-api.md` §1.1](https://github.com/bhirmbani/notex/blob/main/docs/specs/companion-api.md#11-apiversion-compatibility-rule-decided-by-tbr-66)
68
72
  for the full rationale:
69
73
 
package/dist/cli.js CHANGED
@@ -6580,7 +6580,9 @@ var ERROR_CODES = {
6580
6580
  notFound: "not_found",
6581
6581
  graphUnreadable: "graph_unreadable",
6582
6582
  invalidRequest: "invalid_request",
6583
- graphLoading: "graph_loading"
6583
+ graphLoading: "graph_loading",
6584
+ forbidden: "forbidden",
6585
+ notexApiError: "notex_api_error"
6584
6586
  };
6585
6587
 
6586
6588
  class OpError extends Error {
@@ -7012,7 +7014,9 @@ var ERROR_STATUS = {
7012
7014
  [ERROR_CODES.notFound]: 404,
7013
7015
  [ERROR_CODES.graphUnreadable]: 409,
7014
7016
  [ERROR_CODES.invalidRequest]: 422,
7015
- [ERROR_CODES.graphLoading]: 503
7017
+ [ERROR_CODES.graphLoading]: 503,
7018
+ [ERROR_CODES.forbidden]: 403,
7019
+ [ERROR_CODES.notexApiError]: 502
7016
7020
  };
7017
7021
  function createHandler(opts) {
7018
7022
  return async (req) => {
@@ -7188,10 +7192,10 @@ function startNodeServer(opts) {
7188
7192
  }
7189
7193
  };
7190
7194
  }
7191
- async function handleRequest(req, res, fetch) {
7195
+ async function handleRequest(req, res, fetch2) {
7192
7196
  try {
7193
7197
  const request = await toWebRequest(req);
7194
- const response = await fetch(request);
7198
+ const response = await fetch2(request);
7195
7199
  res.statusCode = response.status;
7196
7200
  response.headers.forEach((value, key) => res.setHeader(key, value));
7197
7201
  res.end(Buffer.from(await response.arrayBuffer()));
@@ -33794,34 +33798,107 @@ function createGraphTools(ctx) {
33794
33798
  };
33795
33799
  }
33796
33800
  var LINK_MESSAGE = "Not linked to a Notex Repository — run `npx notex-companion link`";
33797
- function notexStubHandler(ctx, notImplementedText) {
33798
- return () => {
33799
- const state = ctx.getConfigState();
33800
- if (state.kind === "unlinked")
33801
- return { isError: true, content: [{ type: "text", text: LINK_MESSAGE }] };
33802
- return { isError: true, content: [{ type: "text", text: notImplementedText }] };
33803
- };
33804
- }
33805
- function createNotexToolStubs(ctx) {
33806
- const notImplemented = (name) => `${name} is linked but not yet implemented — see TBR-72`;
33801
+ var MAX_INLINE_ANSWER_BYTES = 4096;
33802
+ function requireLinked(ctx) {
33803
+ const state = ctx.getConfigState();
33804
+ if (state.kind === "unlinked")
33805
+ return { ok: false, error: { isError: true, content: [{ type: "text", text: LINK_MESSAGE }] } };
33806
+ return { ok: true, config: state.config };
33807
+ }
33808
+ function fromNotexError(err) {
33809
+ if (err instanceof OpError)
33810
+ return errorResult(err);
33811
+ throw err;
33812
+ }
33813
+ function truncateUtf8(text, maxBytes) {
33814
+ const buf = Buffer.from(text, "utf8");
33815
+ if (buf.length <= maxBytes)
33816
+ return text;
33817
+ let end = maxBytes;
33818
+ while (end > 0 && (buf[end - 1] & 192) === 128)
33819
+ end--;
33820
+ if (end > 0) {
33821
+ const lead = buf[end - 1];
33822
+ const seqLen = lead >= 240 ? 4 : lead >= 224 ? 3 : lead >= 192 ? 2 : 1;
33823
+ if (end - 1 + seqLen > maxBytes)
33824
+ end--;
33825
+ }
33826
+ return buf.subarray(0, end).toString("utf8");
33827
+ }
33828
+ function summarizeAnswer(file2) {
33829
+ if (file2.contentType === "upload")
33830
+ return { id: file2.id, name: file2.name, contentType: "upload", createdAt: file2.createdAt };
33831
+ const bytes = Buffer.byteLength(file2.content, "utf8");
33832
+ if (bytes <= MAX_INLINE_ANSWER_BYTES) {
33833
+ return { id: file2.id, name: file2.name, contentType: "text", content: file2.content, createdAt: file2.createdAt };
33834
+ }
33835
+ return { id: file2.id, name: file2.name, contentType: "text", content: truncateUtf8(file2.content, MAX_INLINE_ANSWER_BYTES), truncated: true, createdAt: file2.createdAt };
33836
+ }
33837
+ function createNotexTools(ctx) {
33807
33838
  return {
33808
33839
  notex_list_questions: {
33809
33840
  description: "List the bound Repository's Questions.",
33810
33841
  inputSchema: {},
33811
- handler: notexStubHandler(ctx, notImplemented("notex_list_questions"))
33842
+ handler: async () => {
33843
+ const linked = requireLinked(ctx);
33844
+ if (!linked.ok)
33845
+ return linked.error;
33846
+ try {
33847
+ const client = ctx.getNotexClient(linked.config);
33848
+ const questions = await client.listContexts(linked.config.organizationId, linked.config.repositoryId);
33849
+ const text = questions.length === 0 ? "No Questions yet." : questions.map((q) => `${q.id} — ${q.question}`).join(`
33850
+ `);
33851
+ return { content: [{ type: "text", text }], structuredContent: { questions } };
33852
+ } catch (err) {
33853
+ return fromNotexError(err);
33854
+ }
33855
+ }
33812
33856
  },
33813
33857
  notex_get_question: {
33814
33858
  description: "A Question plus its Answers.",
33815
33859
  inputSchema: { questionId: exports_external.string().min(1) },
33816
- handler: notexStubHandler(ctx, notImplemented("notex_get_question"))
33860
+ handler: async (args) => {
33861
+ const linked = requireLinked(ctx);
33862
+ if (!linked.ok)
33863
+ return linked.error;
33864
+ const { questionId } = args;
33865
+ try {
33866
+ const client = ctx.getNotexClient(linked.config);
33867
+ const question = await client.getContext(linked.config.organizationId, questionId);
33868
+ if (question.repositoryId !== linked.config.repositoryId) {
33869
+ return errorResult(new OpError("not_found", `Unknown Question id: ${questionId}`));
33870
+ }
33871
+ const files = await client.listFiles(linked.config.organizationId, questionId);
33872
+ const answers = files.map(summarizeAnswer);
33873
+ const text = `${question.question}
33874
+
33875
+ ${answers.length} answer(s)`;
33876
+ return { content: [{ type: "text", text }], structuredContent: { question, answers } };
33877
+ } catch (err) {
33878
+ return fromNotexError(err);
33879
+ }
33880
+ }
33817
33881
  },
33818
33882
  notex_get_answer: {
33819
33883
  description: "Full Answer content.",
33820
33884
  inputSchema: { answerId: exports_external.string().min(1) },
33821
- handler: notexStubHandler(ctx, notImplemented("notex_get_answer"))
33885
+ handler: async (args) => {
33886
+ const linked = requireLinked(ctx);
33887
+ if (!linked.ok)
33888
+ return linked.error;
33889
+ const { answerId } = args;
33890
+ try {
33891
+ const client = ctx.getNotexClient(linked.config);
33892
+ const answer = await client.getFile(linked.config.organizationId, answerId);
33893
+ const text = answer.contentType === "upload" ? `${answer.name} (upload, no readable content)` : answer.content;
33894
+ return { content: [{ type: "text", text }], structuredContent: { answer } };
33895
+ } catch (err) {
33896
+ return fromNotexError(err);
33897
+ }
33898
+ }
33822
33899
  },
33823
33900
  notex_save_answer: {
33824
- description: "Save a graph-drafted Answer. Strictly additive — always creates a new Answer, never updates or deletes. Exactly one of question/questionId.",
33901
+ description: 'Save a graph-drafted Answer. Strictly additive — always creates a new Answer, never updates or deletes. Exactly one of question/questionId. `name` is the Answer\'s title, shown alongside the question in the Notex UI — write a short distinct label (e.g. "Auth flow overview"), not a restatement of the question text. `content` is the drafted prose itself, without the footer.',
33825
33902
  inputSchema: {
33826
33903
  question: exports_external.string().min(1).optional(),
33827
33904
  questionId: exports_external.string().min(1).optional(),
@@ -33829,11 +33906,111 @@ function createNotexToolStubs(ctx) {
33829
33906
  content: exports_external.string().min(1),
33830
33907
  sourceNodeIds: exports_external.array(exports_external.string()).min(1)
33831
33908
  },
33832
- handler: notexStubHandler(ctx, notImplemented("notex_save_answer"))
33909
+ handler: async (args) => {
33910
+ const linked = requireLinked(ctx);
33911
+ if (!linked.ok)
33912
+ return linked.error;
33913
+ const { config: config3 } = linked;
33914
+ const a = args;
33915
+ if (a.question === undefined === (a.questionId === undefined)) {
33916
+ return errorResult(new OpError("invalid_request", "Exactly one of question or questionId is required"));
33917
+ }
33918
+ const graphState = ctx.getGraphState();
33919
+ if (graphState.kind === "error")
33920
+ return errorResult(graphState.error);
33921
+ const { index } = graphState;
33922
+ const uniqueIds = [...new Set(a.sourceNodeIds)];
33923
+ const unresolved = uniqueIds.filter((id) => !ctx.retrievalLog.has(id));
33924
+ if (unresolved.length > 0) {
33925
+ return errorResult(new OpError("invalid_request", `sourceNodeIds cites id(s) not returned by this session's graph_query/graph_node/graph_path: ${unresolved.join(", ")}`));
33926
+ }
33927
+ const sources = uniqueIds.map((id) => {
33928
+ const projected = index.project(index.nodesById.get(id));
33929
+ return { file: projected.sourceFile, location: projected.sourceLocation };
33930
+ });
33931
+ const footer = buildFooter(index.stamp, sources);
33932
+ const fullContent = a.content + footer;
33933
+ try {
33934
+ const client = ctx.getNotexClient(config3);
33935
+ let targetQuestionId;
33936
+ let createdNewQuestion = false;
33937
+ if (a.question !== undefined) {
33938
+ const created = await client.createContext(config3.organizationId, config3.repositoryId, a.question);
33939
+ targetQuestionId = created.id;
33940
+ createdNewQuestion = true;
33941
+ } else {
33942
+ const existing = await client.getContext(config3.organizationId, a.questionId);
33943
+ if (existing.repositoryId !== config3.repositoryId) {
33944
+ return errorResult(new OpError("not_found", `Unknown Question id: ${a.questionId}`));
33945
+ }
33946
+ targetQuestionId = existing.id;
33947
+ }
33948
+ let file2;
33949
+ try {
33950
+ file2 = await client.createFile(config3.organizationId, targetQuestionId, {
33951
+ name: a.name,
33952
+ contentType: "text",
33953
+ content: fullContent
33954
+ });
33955
+ } catch (fileErr) {
33956
+ if (createdNewQuestion)
33957
+ await client.deleteContext(config3.organizationId, targetQuestionId).catch(() => {});
33958
+ throw fileErr;
33959
+ }
33960
+ const text = `Saved Answer "${file2.name}" (${file2.id}) to Question ${targetQuestionId}.`;
33961
+ return { content: [{ type: "text", text }], structuredContent: { answerId: file2.id, questionId: targetQuestionId, footer } };
33962
+ } catch (err) {
33963
+ return fromNotexError(err);
33964
+ }
33965
+ }
33833
33966
  }
33834
33967
  };
33835
33968
  }
33836
33969
 
33970
+ // src/notexClient.ts
33971
+ var DEFAULT_API_URL = "http://localhost:3000";
33972
+ function resolveApiUrl(env = process.env) {
33973
+ return env.NOTEX_API_URL?.trim() || DEFAULT_API_URL;
33974
+ }
33975
+ async function request(config3, fetchImpl, method, path2, body) {
33976
+ let res;
33977
+ try {
33978
+ res = await fetchImpl(`${config3.baseUrl}${path2}`, {
33979
+ method,
33980
+ headers: {
33981
+ "x-api-key": config3.apiKey,
33982
+ ...body !== undefined ? { "content-type": "application/json" } : {}
33983
+ },
33984
+ body: body !== undefined ? JSON.stringify(body) : undefined
33985
+ });
33986
+ } catch (err) {
33987
+ throw new OpError(ERROR_CODES.notexApiError, `Could not reach the Notex API at ${config3.baseUrl}`, err);
33988
+ }
33989
+ if (res.status === 401)
33990
+ throw new OpError(ERROR_CODES.unauthorized, "Notex rejected the API key");
33991
+ if (res.status === 403)
33992
+ throw new OpError(ERROR_CODES.forbidden, "Not authorized for this Project");
33993
+ if (res.status === 404)
33994
+ throw new OpError(ERROR_CODES.notFound, "Not found in Notex");
33995
+ if (!res.ok) {
33996
+ const body2 = await res.json().catch(() => ({}));
33997
+ throw new OpError(ERROR_CODES.notexApiError, body2.error?.message ?? `Notex API error (HTTP ${res.status})`);
33998
+ }
33999
+ return await res.json();
34000
+ }
34001
+ var enc = (id) => encodeURIComponent(id);
34002
+ function createNotexClient(config3, fetchImpl = fetch) {
34003
+ return {
34004
+ listContexts: (organizationId, repositoryId) => request(config3, fetchImpl, "GET", `/api/v1/organizations/${enc(organizationId)}/repositories/${enc(repositoryId)}/contexts`),
34005
+ getContext: (organizationId, id) => request(config3, fetchImpl, "GET", `/api/v1/organizations/${enc(organizationId)}/contexts/${enc(id)}`),
34006
+ createContext: (organizationId, repositoryId, question) => request(config3, fetchImpl, "POST", `/api/v1/organizations/${enc(organizationId)}/repositories/${enc(repositoryId)}/contexts`, { question }),
34007
+ listFiles: (organizationId, contextId) => request(config3, fetchImpl, "GET", `/api/v1/organizations/${enc(organizationId)}/contexts/${enc(contextId)}/files`),
34008
+ getFile: (organizationId, id) => request(config3, fetchImpl, "GET", `/api/v1/organizations/${enc(organizationId)}/files/${enc(id)}`),
34009
+ createFile: (organizationId, contextId, file2) => request(config3, fetchImpl, "POST", `/api/v1/organizations/${enc(organizationId)}/contexts/${enc(contextId)}/files`, file2),
34010
+ deleteContext: (organizationId, id) => request(config3, fetchImpl, "DELETE", `/api/v1/organizations/${enc(organizationId)}/contexts/${enc(id)}`)
34011
+ };
34012
+ }
34013
+
33837
34014
  // src/notexConfig.ts
33838
34015
  import { readFileSync as readFileSync3 } from "node:fs";
33839
34016
  import { join as join2 } from "node:path";
@@ -33899,10 +34076,11 @@ function buildMcpServer(checkoutPath) {
33899
34076
  getCurrentHeadSha: createHeadShaCache(checkoutPath),
33900
34077
  getGraphState: () => graphState,
33901
34078
  getConfigState: () => loadNotexConfig(checkoutPath),
33902
- retrievalLog
34079
+ retrievalLog,
34080
+ getNotexClient: (config3) => createNotexClient({ baseUrl: resolveApiUrl(), apiKey: config3.apiKey })
33903
34081
  };
33904
34082
  const server = new McpServer({ name: "notex-companion", version: API_VERSION });
33905
- registerMcpTools(server, { ...createGraphTools(ctx), ...createNotexToolStubs(ctx) });
34083
+ registerMcpTools(server, { ...createGraphTools(ctx), ...createNotexTools(ctx) });
33906
34084
  return server;
33907
34085
  }
33908
34086
  async function startMcpServer(checkoutPath) {
package/dist/client.js CHANGED
@@ -4,7 +4,9 @@ var ERROR_CODES = {
4
4
  notFound: "not_found",
5
5
  graphUnreadable: "graph_unreadable",
6
6
  invalidRequest: "invalid_request",
7
- graphLoading: "graph_loading"
7
+ graphLoading: "graph_loading",
8
+ forbidden: "forbidden",
9
+ notexApiError: "notex_api_error"
8
10
  };
9
11
 
10
12
  class OpError extends Error {
package/dist/index.js CHANGED
@@ -4,7 +4,9 @@ var ERROR_CODES = {
4
4
  notFound: "not_found",
5
5
  graphUnreadable: "graph_unreadable",
6
6
  invalidRequest: "invalid_request",
7
- graphLoading: "graph_loading"
7
+ graphLoading: "graph_loading",
8
+ forbidden: "forbidden",
9
+ notexApiError: "notex_api_error"
8
10
  };
9
11
 
10
12
  class OpError extends Error {
@@ -485,7 +487,9 @@ var ERROR_STATUS = {
485
487
  [ERROR_CODES.notFound]: 404,
486
488
  [ERROR_CODES.graphUnreadable]: 409,
487
489
  [ERROR_CODES.invalidRequest]: 422,
488
- [ERROR_CODES.graphLoading]: 503
490
+ [ERROR_CODES.graphLoading]: 503,
491
+ [ERROR_CODES.forbidden]: 403,
492
+ [ERROR_CODES.notexApiError]: 502
489
493
  };
490
494
  function createHandler(opts) {
491
495
  return async (req) => {
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { OpError } from "./types.js";
3
3
  import type { GraphIndex } from "./graph.js";
4
- import type { NotexConfigState } from "./notexConfig.js";
4
+ import type { NotexConfig, NotexConfigState } from "./notexConfig.js";
5
+ import type { NotexClient } from "./notexClient.js";
5
6
  import type { RetrievalLog } from "./retrievalLog.js";
6
7
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
8
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
@@ -21,6 +22,9 @@ export type McpToolContext = {
21
22
  * run mid-session without a restart. */
22
23
  getConfigState: () => NotexConfigState;
23
24
  retrievalLog: RetrievalLog;
25
+ /** Built fresh from the linked config on every notex_* call — a seam mcpTools.test.ts uses to
26
+ * inject a fake client, and the same "re-verify, don't cache" posture as getConfigState. */
27
+ getNotexClient: (config: NotexConfig) => NotexClient;
24
28
  };
25
29
  export type ToolDef = {
26
30
  description: string;
@@ -29,10 +33,4 @@ export type ToolDef = {
29
33
  };
30
34
  export declare function registerMcpTools(server: McpServer, tools: Record<string, ToolDef>): void;
31
35
  export declare function createGraphTools(ctx: McpToolContext): Record<string, ToolDef>;
32
- /**
33
- * Argument shapes only, per §2.3/§2.4 — every handler errors. The actual Notex Worker binding
34
- * (auth, requests, the retrieval-log-checked write) is TBR-72's job. What TBR-69 owes here is
35
- * §4.1's acceptance bar: the tools are listed, never hidden, and a missing/malformed
36
- * `.notex/notex.json` produces the documented actionable error rather than a stack trace.
37
- */
38
- export declare function createNotexToolStubs(ctx: McpToolContext): Record<string, ToolDef>;
36
+ export declare function createNotexTools(ctx: McpToolContext): Record<string, ToolDef>;
@@ -0,0 +1,43 @@
1
+ export type NotexContext = {
2
+ id: string;
3
+ repositoryId: string;
4
+ question: string;
5
+ createdAt: string;
6
+ };
7
+ export type NotexFile = {
8
+ id: string;
9
+ contextId: string;
10
+ name: string;
11
+ contentType: "text" | "upload";
12
+ content: string;
13
+ createdAt: string;
14
+ };
15
+ export type NotexClientConfig = {
16
+ baseUrl: string;
17
+ apiKey: string;
18
+ };
19
+ type FetchImpl = typeof fetch;
20
+ /** `NOTEX_API_URL` overrides the default — there is no production Notex origin baked into this
21
+ * package yet, and `.notex/notex.json` (notex-mcp-server.md §4) deliberately carries no URL. */
22
+ export declare function resolveApiUrl(env?: {
23
+ NOTEX_API_URL?: string;
24
+ }): string;
25
+ export declare function createNotexClient(config: NotexClientConfig, fetchImpl?: FetchImpl): {
26
+ listContexts: (organizationId: string, repositoryId: string) => Promise<NotexContext[]>;
27
+ getContext: (organizationId: string, id: string) => Promise<NotexContext>;
28
+ createContext: (organizationId: string, repositoryId: string, question: string) => Promise<NotexContext>;
29
+ listFiles: (organizationId: string, contextId: string) => Promise<NotexFile[]>;
30
+ getFile: (organizationId: string, id: string) => Promise<NotexFile>;
31
+ createFile: (organizationId: string, contextId: string, file: {
32
+ name: string;
33
+ contentType: "text" | "upload";
34
+ content: string;
35
+ }) => Promise<NotexFile>;
36
+ /** Used only for best-effort rollback of a Question just created by notex_save_answer when
37
+ * its Answer write then fails — never exposed as its own MCP tool (§2.4/§9: no delete tool). */
38
+ deleteContext: (organizationId: string, id: string) => Promise<{
39
+ success: true;
40
+ }>;
41
+ };
42
+ export type NotexClient = ReturnType<typeof createNotexClient>;
43
+ export {};
package/dist/types.d.ts CHANGED
@@ -55,6 +55,11 @@ export declare const ERROR_CODES: {
55
55
  readonly graphUnreadable: "graph_unreadable";
56
56
  readonly invalidRequest: "invalid_request";
57
57
  readonly graphLoading: "graph_loading";
58
+ /** The Notex API rejected a request as 403 — key holder lacks Grant access to the Project. */
59
+ readonly forbidden: "forbidden";
60
+ /** Any other non-2xx from the Notex API (rate limit, 5xx, ...) — notex-mcp-server.md §4.1
61
+ * reuses this module's error vocabulary rather than inventing a second taxonomy. */
62
+ readonly notexApiError: "notex_api_error";
58
63
  };
59
64
  export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
60
65
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notex-companion",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Local retrieval companion for Notex — reads a checkout's graphify-out/graph.json and serves deterministic search/query/path/node lookups over loopback HTTP and MCP stdio. No LLM, no graph building, no network beyond 127.0.0.1.",
5
5
  "keywords": ["notex", "graphify", "mcp", "code-graph"],
6
6
  "license": "MIT",