@orboto/mcp 0.207.0 → 0.208.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/dist/server.js CHANGED
@@ -28,6 +28,7 @@ import { agentMessageWorkToolConfig, makeAgentMessageWorkHandler } from './tools
28
28
  import { agentMessagesToolConfig, makeAgentMessagesHandler } from './tools/agent-messages.js';
29
29
  import { agentWakesToolConfig, makeAgentWakesHandler } from './tools/agent-wakes.js';
30
30
  import { sessionCheckToolConfig, makeSessionCheckHandler } from './tools/session-check.js';
31
+ import { knowledgeAskToolConfig, makeKnowledgeAskHandler, knowledgeAddToolConfig, makeKnowledgeAddHandler } from './tools/knowledge.js';
31
32
  import { listProjectsToolConfig, makeListProjectsHandler } from './tools/list-projects.js';
32
33
  import { getProjectToolConfig, makeGetProjectHandler } from './tools/get-project.js';
33
34
  import { projectReadinessToolConfig, makeProjectReadinessHandler } from './tools/project-readiness.js';
@@ -45,7 +46,7 @@ import { queryToolConfig, makeQueryHandler } from './tools/query.js';
45
46
  import { customerReportToolConfig, makeCustomerReportHandler } from './tools/customer-report.js';
46
47
  import { requirementsSpecToolConfig, makeRequirementsSpecHandler } from './tools/requirements-spec.js';
47
48
  import { specCheckToolConfig, makeSpecCheckHandler } from './tools/spec-check.js';
48
- import { wikiIngestUrlToolConfig, makeWikiIngestUrlHandler, wikiAskToolConfig, makeWikiAskHandler, wikiLintToolConfig, makeWikiLintHandler, wikiPlanUpdateToolConfig, makeWikiPlanUpdateHandler, wikiApplyPlanToolConfig, makeWikiApplyPlanHandler, wikiRecordToolConfig, makeWikiRecordHandler, wikiAppendSectionToolConfig, makeWikiAppendSectionHandler, wikiFlagStaleToolConfig, makeWikiFlagStaleHandler, wikiSaveAnswerToolConfig, makeWikiSaveAnswerHandler, } from './tools/wiki.js';
49
+ import { wikiIngestUrlToolConfig, makeWikiIngestUrlHandler, wikiAskToolConfig, makeWikiAskHandler, wikiLintToolConfig, makeWikiLintHandler, wikiPlanUpdateToolConfig, makeWikiPlanUpdateHandler, wikiApplyPlanToolConfig, makeWikiApplyPlanHandler, wikiRecordToolConfig, makeWikiRecordHandler, wikiAppendSectionToolConfig, makeWikiAppendSectionHandler, wikiFlagStaleToolConfig, makeWikiFlagStaleHandler, wikiSaveAnswerToolConfig, makeWikiSaveAnswerHandler, wikiRunsToolConfig, makeWikiRunsHandler } from './tools/wiki.js';
49
50
  import { personalFactListToolConfig, makePersonalFactListHandler, personalFactAddToolConfig, makePersonalFactAddHandler, personalFactUpdateToolConfig, makePersonalFactUpdateHandler, personalFactDeleteToolConfig, makePersonalFactDeleteHandler, } from './tools/personal-facts.js';
50
51
  import { listDocSpacesToolConfig, makeListDocSpacesHandler, getDocToolConfig, makeGetDocHandler, createDocSpaceToolConfig, makeCreateDocSpaceHandler, updateDocSpaceToolConfig, makeUpdateDocSpaceHandler, deleteDocSpaceToolConfig, makeDeleteDocSpaceHandler, listDocsInSpaceToolConfig, makeListDocsInSpaceHandler, createDocToolConfig, makeCreateDocHandler, updateDocToolConfig, makeUpdateDocHandler, deleteDocToolConfig, makeDeleteDocHandler, moveDocToolConfig, makeMoveDocHandler, duplicateDocSpaceToolConfig, makeDuplicateDocSpaceHandler, resolveDocSmartLinksToolConfig, makeResolveDocSmartLinksHandler, } from './tools/docs.js';
51
52
  import { searchDocsToolConfig, makeSearchDocsHandler, editDocToolConfig, makeEditDocHandler, editDocSectionToolConfig, makeEditDocSectionHandler, } from './tools/doc-edits.js';
@@ -211,6 +212,8 @@ export async function buildOrbotoMcpServer(opts) {
211
212
  reg('orboto_messages', agentMessagesToolConfig, makeAgentMessagesHandler(client));
212
213
  reg('orboto_agent_wakes', agentWakesToolConfig, makeAgentWakesHandler(client));
213
214
  reg('orboto_session_check', sessionCheckToolConfig, makeSessionCheckHandler(client));
215
+ reg('orboto_knowledge_ask', knowledgeAskToolConfig, makeKnowledgeAskHandler(client));
216
+ reg('orboto_knowledge_add', knowledgeAddToolConfig, makeKnowledgeAddHandler(client));
214
217
  reg('orboto_agent_broadcast', agentBroadcastToolConfig, makeAgentBroadcastHandler(client));
215
218
  reg('orboto_list_projects', listProjectsToolConfig, makeListProjectsHandler(client));
216
219
  reg('orboto_get_project', getProjectToolConfig, makeGetProjectHandler(client));
@@ -269,6 +272,7 @@ export async function buildOrbotoMcpServer(opts) {
269
272
  reg('orboto_wiki_ingest_url', wikiIngestUrlToolConfig, makeWikiIngestUrlHandler(client));
270
273
  reg('orboto_wiki_ask', wikiAskToolConfig, makeWikiAskHandler(client));
271
274
  reg('orboto_wiki_lint', wikiLintToolConfig, makeWikiLintHandler(client));
275
+ reg('orboto_wiki_runs', wikiRunsToolConfig, makeWikiRunsHandler(client));
272
276
  reg('orboto_wiki_plan_update', wikiPlanUpdateToolConfig, makeWikiPlanUpdateHandler(client));
273
277
  reg('orboto_wiki_apply_plan', wikiApplyPlanToolConfig, makeWikiApplyPlanHandler(client));
274
278
  reg('orboto_wiki_record', wikiRecordToolConfig, makeWikiRecordHandler(client));
@@ -11,7 +11,7 @@
11
11
  import { z } from 'zod';
12
12
  export const listAgentDriftToolConfig = {
13
13
  title: 'List agent drift events',
14
- description: 'List agent drift events (commits with no ticket key + no timer, or ticket transitions without a summary comment), newest first, with aggregate metrics. Filter by `userId`, `driftType` (untracked_commit | transition_without_summary), `from`/`to` ISO dates, and `resolved`. The response `enabled` flag is false when the operator has not turned drift detection on. Requires `admin:agent_drift:read`.',
14
+ description: 'List agent drift events newest first, with aggregate metrics. A drift event is a commit with no ticket key and no timer, or a ticket transition without a summary comment. Filter by `userId`, `driftType` (untracked_commit | transition_without_summary), `from`/`to` ISO dates, and `resolved`. The response `enabled` flag is false when the operator has not turned drift detection on. Requires `admin:agent_drift:read`.',
15
15
  inputSchema: z.object({
16
16
  userId: z.string().uuid().optional().describe('Filter to one user.'),
17
17
  driftType: z.enum(['untracked_commit', 'transition_without_summary']).optional(),
@@ -18,7 +18,7 @@ function renderBlock(b) {
18
18
  }
19
19
  export const listAgentInstructionsToolConfig = {
20
20
  title: 'MANAGE the workspace coding-agent rule blocks (admin)',
21
- description: 'ADMIN/MANAGEMENT tool - lists the individual rule BLOCKS at a scope (workspace: needs admin:ai:read; project: needs project:edit; personal: your own) so they can be edited/toggled/reordered. This is NOT how you read the rules to follow. To LOAD the rules you must follow as an agent, call orboto_session_start instead - it returns the complete assembled rule set.',
21
+ description: 'ADMIN tool: lists the rule BLOCKS at a scope for editing, not the rules to follow. Scopes: workspace needs admin:ai:read, project needs project:edit, personal is your own; blocks can be edited, toggled and reordered. This is NOT how you read the rules to follow. To LOAD the rules you must follow as an agent, call orboto_session_start instead - it returns the complete assembled rule set.',
22
22
  inputSchema: z.object({
23
23
  scope: z.enum(['workspace', 'customer', 'project', 'personal']).default('workspace'),
24
24
  projectId: z.string().uuid().optional().describe('Required for scope=project.'),
@@ -72,7 +72,7 @@ export function makeListAgentInstructionsHandler(client) {
72
72
  }
73
73
  export const createAgentInstructionToolConfig = {
74
74
  title: 'Add a custom coding-agent rule block',
75
- description: 'Create a custom rule block at a scope: workspace (every agent; admin:ai:write), customer (every project of one customer; customer:write), project (one project; project:edit), or personal (your own). For scope=customer pass customerId; for scope=project pass projectId.'
75
+ description: 'Create a custom rule block at a scope (workspace, customer, project or personal). Workspace reaches every agent (admin:ai:write), customer every project of one customer (customer:write), project one project (project:edit), personal only you. For scope=customer pass customerId; for scope=project pass projectId.'
76
76
  + RULE_WRITING_CONTRACT,
77
77
  inputSchema: z.object({
78
78
  title: z.string().min(1).max(120),
@@ -13,7 +13,7 @@ import { mcpInstanceToken } from './shared.js';
13
13
  const PartySchema = z.object({ userId: z.string(), email: z.string(), sessionId: z.string().nullable(), role: z.string().nullable(), label: z.string() });
14
14
  export const agentMessagesToolConfig = {
15
15
  title: 'Agent inbox and message work',
16
- description: 'Your inbox as THIS session sees it: mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true shows the whole account (ORB-2136). messageWork carries durable work ownership - claim before executing. ackIds acks: it completes an unclaimed request; claimed work with open steps is refused until messageWork completes or blocks it. dismiss is the third answer: not_mine hides it here and tells the sender, obsolete/duplicate close it for the account (note or duplicateOf required). Never leave mail you read and judged. Reply via orboto_agent_notify with threadId (toSessionRef = from.sessionId reaches that instance).',
16
+ description: 'Your inbox as THIS session sees it. It holds mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true shows the whole account (ORB-2136). messageWork carries durable work ownership - claim before executing. ackIds acks: it completes an unclaimed request; claimed work with open steps is refused until messageWork completes or blocks it. dismiss is the third answer: not_mine hides it here and tells the sender, obsolete/duplicate close it for the account (note or duplicateOf required). Never leave mail you read and judged. Reply via orboto_agent_notify with threadId (toSessionRef = from.sessionId reaches that instance).',
17
17
  inputSchema: z.object({
18
18
  messageWork: z.record(z.unknown()).optional()
19
19
  .describe('Work envelope {messageId, mutation, cursor, limit, openOnly}; schema: orboto_api_search.'),
@@ -5,7 +5,7 @@
5
5
  import { z } from 'zod';
6
6
  export const agentWakesToolConfig = {
7
7
  title: 'Wake ledger',
8
- description: 'Who woke this account\'s sessions, which visibility rule delivered the message (addressed | scope_project | scope_ticket | broadcast | unscoped_session | account_view), what the woken session did with it (handled | not_mine | obsolete | duplicate | expired | pending) and what the wake cost in context tokens. `stats: true` returns delivery precision per woken session and per sender over `hours` (operator read, needs admin:agents:read); `wakes`, `precision` and the median latency count deliveries (`channel` | `follow` | `poll`) only, `backlogSeen` is the separate count of `nudge` rows - a backlog sighting, not a wake. Without `stats` it returns the ledger rows of your own account, newest first. Use it to prove a misrouting sender instead of describing it in prose.',
8
+ description: 'The wake ledger: who woke this account\'s sessions and at what cost. It names the visibility rule that delivered the message (addressed | scope_project | scope_ticket | broadcast | unscoped_session | account_view), what the woken session did with it (handled | not_mine | obsolete | duplicate | expired | pending) and what the wake cost in context tokens. `stats: true` returns delivery precision per woken session and per sender over `hours` (operator read, needs admin:agents:read); `wakes`, `precision` and the median latency count deliveries (`channel` | `follow` | `poll`) only, `backlogSeen` is the separate count of `nudge` rows - a backlog sighting, not a wake. Without `stats` it returns the ledger rows of your own account, newest first. Use it to prove a misrouting sender instead of describing it in prose.',
9
9
  inputSchema: z.object({
10
10
  stats: z.boolean().default(false).describe('Precision per session and per sender instead of rows.'),
11
11
  hours: z.number().int().min(1).max(720).optional().describe('Stats window, default 24.'),
@@ -69,7 +69,7 @@ export function makeListBackupsHandler(client) {
69
69
  }
70
70
  export const downloadBackupToolConfig = {
71
71
  title: 'Download a stored backup run',
72
- description: 'Download a stored backup run by run id (from orboto_list_backups) as a base64 resource attachment; an encrypted run downloads sealed and needs its key to restore. Requires admin:backup:read.',
72
+ description: 'Download a stored backup run by run id as a base64 resource attachment. The id comes from orboto_list_backups; an encrypted run downloads sealed and needs its key to restore. Requires admin:backup:read.',
73
73
  inputSchema: z.object({
74
74
  runId: z.string().uuid().describe('Backup run id from orboto_list_backups.'),
75
75
  }).shape,
@@ -22,7 +22,7 @@ const TicketDraftSchema = z.object({
22
22
  });
23
23
  export const bulkCreateTicketsToolConfig = {
24
24
  title: 'Create many tickets in one call',
25
- description: 'Create up to 50 tickets in one call - the right tool whenever you are about to create more than ~3 tickets (milestone planning, epic breakdown, import). Per-draft error isolation: a rejected draft (bad label, language block, hard duplicate-block) is reported in `failed` while the rest are still created. Duplicate detection runs per draft and is reported compactly - one line per flagged draft in `duplicateFlags`; review those before treating them as new work. A call-level `milestone` / `parentTicketKey` applies to every draft unless the draft overrides it. Drafts are created in array order, so a draft can NOT reference a sibling draft as parent - create parents first (or in a first call).',
25
+ description: 'Create up to 50 tickets in one call. It is the right tool whenever you are about to create more than ~3 tickets (milestone planning, epic breakdown, import). Per-draft error isolation: a rejected draft (bad label, language block, hard duplicate-block) is reported in `failed` while the rest are still created. Duplicate detection runs per draft and is reported compactly - one line per flagged draft in `duplicateFlags`; review those before treating them as new work. A call-level `milestone` / `parentTicketKey` applies to every draft unless the draft overrides it. Drafts are created in array order, so a draft can NOT reference a sibling draft as parent - create parents first (or in a first call).',
26
26
  inputSchema: z.object({
27
27
  projectKey: z.string().min(1).describe('Project key - all drafts land here.'),
28
28
  tickets: z.array(TicketDraftSchema).min(1).max(50).describe('The ticket drafts, created in order.'),
@@ -137,7 +137,7 @@ export function makeBulkCreateTicketsHandler(client) {
137
137
  }
138
138
  export const bulkAddTicketDependenciesToolConfig = {
139
139
  title: 'Add many ticket dependencies in one call',
140
- description: 'Create up to 200 blocked-by edges in one call - use instead of repeated orboto_add_ticket_dependency whenever wiring more than ~3 edges (dependency graphs after a bulk create). Each pair is (ticketKey, dependsOnKey) = "ticket is blocked by dependsOn". Per-pair error isolation; an already-existing edge counts as ok, a cycle rejection lands in `failed` with the API\'s reason.',
140
+ description: 'Create up to 200 blocked-by edges in one call. Use it instead of repeated orboto_add_ticket_dependency whenever wiring more than ~3 edges (dependency graphs after a bulk create). Each pair is (ticketKey, dependsOnKey) = "ticket is blocked by dependsOn". Per-pair error isolation; an already-existing edge counts as ok, a cycle rejection lands in `failed` with the API\'s reason.',
141
141
  inputSchema: z.object({
142
142
  pairs: z.array(z.object({
143
143
  ticketKey: z.string().min(3).describe('The blocked ticket.'),
@@ -6,7 +6,7 @@ import { trimSimilarEntries } from './similar-projection.js';
6
6
  import { resolveProjectByKey, resolveTicketByKey } from './shared.js';
7
7
  export const checkSimilarToolConfig = {
8
8
  title: 'Check for similar tickets before creating',
9
- description: 'Run the duplicate-detection pipeline (tsvector + AI-embedding rerank when configured) against a proposed title + description, without creating anything. Returns up to `limit` candidates ranked by similarity. Use this BEFORE `orboto_create_ticket` when you want to confirm a feature is not already tracked - if a high-similarity candidate exists, prefer to comment on / claim / extend it instead of opening a new ticket. Empty result = safe to create. `orboto_create_ticket` runs the same check after the fact and surfaces `similarWarnings` in its response, so this tool is optional but cheaper than a create-then-close round trip. Only an AI-embedding candidate carries an absolute similarity; a full-text candidate prints as "text match" without a percentage (its rank is relative, so the best text match always scores 1.000) and can never trigger the hard duplicate-block, and neither can a match against a CLOSED ticket (ORB-2185). An empty result from a LONG, solution-framed title is weak evidence - detection ranks by term co-occurrence, so also probe with a single distinctive STABLE token (file/component/error-string fragment) and the SYMPTOM wording, not just your intended fix.',
9
+ description: 'Run the duplicate check against a proposed title + description without creating anything. The pipeline is tsvector plus an AI-embedding rerank when configured. Returns up to `limit` candidates ranked by similarity. Use this BEFORE `orboto_create_ticket` when you want to confirm a feature is not already tracked - if a high-similarity candidate exists, prefer to comment on / claim / extend it instead of opening a new ticket. Empty result = safe to create. `orboto_create_ticket` runs the same check after the fact and surfaces `similarWarnings` in its response, so this tool is optional but cheaper than a create-then-close round trip. Only an AI-embedding candidate carries an absolute similarity; a full-text candidate prints as "text match" without a percentage (its rank is relative, so the best text match always scores 1.000) and can never trigger the hard duplicate-block, and neither can a match against a CLOSED ticket (ORB-2185). An empty result from a LONG, solution-framed title is weak evidence - detection ranks by term co-occurrence, so also probe with a single distinctive STABLE token (file/component/error-string fragment) and the SYMPTOM wording, not just your intended fix.',
10
10
  inputSchema: z.object({
11
11
  projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
12
12
  title: z.string().min(1).describe('Proposed ticket title.'),
@@ -11,7 +11,7 @@ import { z } from 'zod';
11
11
  import { resolveTicketByKey } from './shared.js';
12
12
  export const draftCustomerReplyToolConfig = {
13
13
  title: 'Draft a customer-facing reply for a ticket',
14
- description: 'Draft a reply or status update for a CUSTOMER / external reader about a ticket (by key), built server-side from customer-safe facts only (key, title, status, due date, customer summary, non-internal comments, your `ask`) - never from internal notes. Returns the draft, the facts used and what was excluded. Never posts: show it to the user, then `orboto_comment` only after approval. Use it for every customer-facing text.',
14
+ description: 'Draft a customer-facing reply about a ticket (by key). It is built server-side from customer-safe facts only (key, title, status, due date, customer summary, non-internal comments, your `ask`) - never from internal notes. Returns the draft, the facts used and what was excluded. Never posts: show it to the user, then `orboto_comment` only after approval. Use it for every customer-facing text.',
15
15
  inputSchema: z.object({
16
16
  ticketKey: z.string().describe('Ticket key like "ORB-42".'),
17
17
  ask: z.string().min(3).max(1000).describe('What the reply should do, in the user\'s words, e.g. "tell them the fix ships with the next release" or "ask for the browser version".'),
@@ -7,7 +7,7 @@ import { z } from 'zod';
7
7
  import { resolveProjectByKey } from './shared.js';
8
8
  export const getProjectPrimerToolConfig = {
9
9
  title: 'Get project primer (AI Context Pack)',
10
- description: 'Returns the project\'s auto-generated session primer in a single call: the project vocabulary a create has to get right (ticket type enum, the project\'s labels, its status names per category, its open milestone keys), active milestones, ticket counts, recently-closed milestones, top docs, structured primer facts, and recent activity. Primer facts merge three scopes in order Workspace -> Project -> Personal: the Personal block ("Your preferences") is the calling user\'s own personal-scope facts, included only when that user has opted in (ai_preferences_enabled) and never showing another user\'s. Repo briefings (CLAUDE.md / AGENTS.md) are included ONLY when the operator configured them AND the API host can read those files from disk - most deployments will not have them, so do not assume CLAUDE.md / AGENTS.md content is in the response. Token-budget aware - pass `maxTokens` to constrain the output. Input is the project key (e.g. "ORB"), case-insensitive.',
10
+ description: 'Returns the project\'s auto-generated session primer in one call. It carries the project vocabulary a create has to get right (ticket type enum, the project\'s labels, its status names per category, its open milestone keys), active milestones, ticket counts, recently-closed milestones, top docs, structured primer facts, the Knowledge block (the index of the project wiki and of the Operations wiki, their latest log entries and open lint issues), and recent activity. Primer facts merge three scopes in order Workspace -> Project -> Personal: the Personal block ("Your preferences") is the calling user\'s own personal-scope facts, included only when that user has opted in (ai_preferences_enabled) and never showing another user\'s. Repo briefings (CLAUDE.md / AGENTS.md) are included ONLY when the operator configured them AND the API host can read those files from disk - most deployments will not have them, so do not assume CLAUDE.md / AGENTS.md content is in the response. Token-budget aware - pass `maxTokens` to constrain the output. Input is the project key (e.g. "ORB"), case-insensitive.',
11
11
  inputSchema: z.object({
12
12
  projectKey: z.string().min(1).describe('Project key, case-insensitive.'),
13
13
  maxTokens: z.number().int().positive().max(200000).optional()
@@ -15,7 +15,7 @@ import { onboardingText } from '../agent-head.js';
15
15
  const TOPICS = { connect: connectScriptText, onboarding: onboardingText };
16
16
  export const helpToolConfig = {
17
17
  title: 'Full guidance for one orboto tool',
18
- description: 'Return the complete guidance text (workflows, warnings, edge cases) for one orboto tool by name - manifest descriptions are one-line summaries, this is the rest. Call it before first use of an unfamiliar write tool. topic: "connect" returns the step-by-step script for setting a person up, topic: "onboarding" the first five minutes and the work loop.',
18
+ description: 'Return the complete guidance text for one orboto tool by name. Manifest descriptions are one-line summaries; this adds the workflows, warnings and edge cases. Call it before first use of an unfamiliar write tool. topic: "connect" returns the step-by-step script for setting a person up, topic: "onboarding" the first five minutes and the work loop.',
19
19
  inputSchema: z.object({
20
20
  tool: z.string().min(1).max(128).optional().describe('Tool name, e.g. orboto_create_ticket.'),
21
21
  topic: z.enum(['connect', 'onboarding']).optional().describe('Guidance topic instead of a tool.'),
@@ -0,0 +1,35 @@
1
+ /** ORB-2225 - the wiki as the agents' knowledge base: ask it first, file durable facts in it. */
2
+ import { z } from 'zod';
3
+ import { mcpInstanceToken } from './shared.js';
4
+ export const knowledgeAskToolConfig = {
5
+ title: 'Ask the wiki',
6
+ description: 'Ask your session\'s project wikis and the Operations wiki before asking a person; cite docs by key.',
7
+ inputSchema: z.object({ question: z.string().min(3).max(1000), projectKeys: z.array(z.string().min(1).max(32)).max(20).optional() }).shape,
8
+ annotations: { readOnlyHint: true, idempotentHint: true },
9
+ };
10
+ export function makeKnowledgeAskHandler(client) {
11
+ return async (input, extra) => {
12
+ const res = await client.post('/knowledge/ask', input, { instanceToken: mcpInstanceToken(undefined, extra) });
13
+ const where = res.spaces.map((s) => s.spaceKey ?? s.spaceName).join(', ');
14
+ const cites = res.citations.map((c) => `[${c.index}] ${c.title} - ${c.link}`).join('\n');
15
+ return {
16
+ content: [{ type: 'text', text: [res.answer, cites ? `Sources:\n${cites}` : '', `Asked: ${where}.`].filter(Boolean).join('\n\n') }],
17
+ structuredContent: res,
18
+ };
19
+ };
20
+ }
21
+ export const knowledgeAddToolConfig = {
22
+ title: 'File a fact in the wiki',
23
+ description: 'File a durable fact in a project wiki (projectKey) or the Operations wiki, never only in local memory.',
24
+ inputSchema: z.object({
25
+ title: z.string().min(1).max(200), content: z.string().min(1).max(50_000),
26
+ projectKey: z.string().min(1).max(32).optional(), operations: z.boolean().optional(),
27
+ }).shape,
28
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
29
+ };
30
+ export function makeKnowledgeAddHandler(client) {
31
+ return async (input, extra) => {
32
+ const res = await client.post('/knowledge/add', input, { instanceToken: mcpInstanceToken(undefined, extra) });
33
+ return { content: [{ type: 'text', text: res.text }], structuredContent: res };
34
+ };
35
+ }
@@ -0,0 +1,74 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { OrbotoClient as RealClient } from '../orboto-client.js';
3
+ import { knowledgeAddToolConfig, knowledgeAskToolConfig, makeKnowledgeAddHandler, makeKnowledgeAskHandler } from './knowledge.js';
4
+ import { KNOWLEDGE_BLOCK_CHARS, knowledgeLines, makeSessionStartHandler } from './session-start.js';
5
+ import { makeResponseExpandHandler } from './response-expand.js';
6
+ import { makeWikiRunsHandler } from './wiki.js';
7
+ function textOf(result) {
8
+ return result.content[0].text;
9
+ }
10
+ afterEach(() => { vi.restoreAllMocks(); });
11
+ describe('ORB-2225 orboto_knowledge_ask and orboto_knowledge_add', () => {
12
+ it('asks the scope and lists the cited docs and the asked spaces', async () => {
13
+ const post = vi.fn().mockResolvedValue({
14
+ answer: 'Nightly with restic [1].', citations: [{ index: 1, title: 'Backup chain', link: '/spaces/s/docs/d' }], mode: 'keyword', abstained: false,
15
+ source: 'session', spaces: [{ spaceKey: 'ACME-S1', spaceName: 'ACME', projectKey: 'ACME' }, { spaceKey: 'SPACE-3', spaceName: 'Operations', projectKey: null }],
16
+ });
17
+ const result = await makeKnowledgeAskHandler({ post })({ question: 'where does the backup run?' }, { sessionId: 'abc' });
18
+ expect(post).toHaveBeenCalledWith('/knowledge/ask', { question: 'where does the backup run?' }, { instanceToken: 'mcp-abc' });
19
+ expect(textOf(result)).toContain('[1] Backup chain - /spaces/s/docs/d');
20
+ expect(textOf(result)).toContain('Asked: ACME-S1, SPACE-3.');
21
+ });
22
+ it('files a fact and returns the server sentence naming where it landed', async () => {
23
+ const post = vi.fn().mockResolvedValue({ action: 'recorded', text: 'Recorded in the ACME-S1 wiki: ACME-D7 "Build host". Cite it by key.', pages: [], source: null });
24
+ const result = await makeKnowledgeAddHandler({ post })({ title: 'Build host', content: 'build.example.org', projectKey: 'ACME' });
25
+ expect(post.mock.calls[0][0]).toBe('/knowledge/add');
26
+ expect(textOf(result)).toContain('ACME-D7');
27
+ expect(result.structuredContent).toMatchObject({ action: 'recorded' });
28
+ });
29
+ it('both tools carry a title and annotations; ask is read-only, add is not', () => {
30
+ expect(knowledgeAskToolConfig.title).toBeTruthy();
31
+ expect(knowledgeAskToolConfig.annotations.readOnlyHint).toBe(true);
32
+ expect(knowledgeAddToolConfig.title).toBeTruthy();
33
+ expect(knowledgeAddToolConfig.annotations.readOnlyHint).toBe(false);
34
+ });
35
+ });
36
+ describe('ORB-2225 the Knowledge block of orboto_session_start', () => {
37
+ it('prints the block from GET /knowledge with the session header', async () => {
38
+ const seen = [];
39
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
40
+ const u = new URL(url.toString());
41
+ const headers = new Headers(init?.headers);
42
+ seen.push({ path: u.pathname, session: headers.get('x-orboto-agent-session') });
43
+ const body = u.pathname === '/agent-instructions' ? { instructions: 'claim -> commit -> close', rulesHash: 'fixture' } : u.pathname === '/knowledge'
44
+ ? { source: 'session', spaces: [{ spaceKey: 'ACME-S1', spaceName: 'ACME', projectKey: 'ACME', pages: 3, sources: 1, openLintIssues: 0, indexDoc: { key: 'ACME-D1' } }], missing: [], text: '## Knowledge\nIndex ACME-D1:\n- Build host' }
45
+ : {};
46
+ return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
47
+ });
48
+ const client = new RealClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
49
+ const result = await makeSessionStartHandler(client)({}, { sessionId: 'k1' });
50
+ expect(seen.find((c) => c.path === '/knowledge')?.session).toBe('mcp-k1');
51
+ expect(textOf(result)).toContain('## Knowledge\nIndex ACME-D1:\n- Build host');
52
+ expect(result.structuredContent).toMatchObject({ knowledge: { source: 'session', spaces: [{ spaceKey: 'ACME-S1', indexDocKey: 'ACME-D1', pages: 3 }] } });
53
+ });
54
+ it('cuts a long block at a line and serves the rest through a handle', async () => {
55
+ const text = ['## Knowledge', ...Array.from({ length: 200 }, (_, i) => `- ACME-D${i} Page ${i}`)].join('\n');
56
+ const block = knowledgeLines(text);
57
+ expect(block.lines.join('\n').length).toBeLessThan(KNOWLEDGE_BLOCK_CHARS + 200);
58
+ expect(block.lines.at(-1)).toContain(`handle: "${block.handle}"`);
59
+ const expanded = await makeResponseExpandHandler()({ handle: block.handle, path: 'knowledge' });
60
+ expect(textOf(expanded)).toContain('- ACME-D199 Page 199');
61
+ });
62
+ });
63
+ describe('ORB-2225 orboto_wiki_runs', () => {
64
+ it('lists the failed runs with their error and retries one by id', async () => {
65
+ const get = vi.fn().mockResolvedValue({ runs: [{ id: 'r1', sourceKey: 'ACME-D9', sourceTitle: 'Runner notes', status: 'failed', error: 'answer budget spent', createdAt: '2026-09-27T08:00:00Z' }] });
66
+ const post = vi.fn().mockResolvedValue({ runId: 'r1', status: 'applied', queued: false, error: null });
67
+ const handler = makeWikiRunsHandler({ get, post });
68
+ expect(textOf(await handler({ spaceId: 'ACME-S1', status: 'failed' }))).toContain('r1 failed ACME-D9 "Runner notes" 2026-09-27T08:00:00Z - answer budget spent');
69
+ expect(get).toHaveBeenCalledWith('/spaces/ACME-S1/llm-wiki/ingest-runs?status=failed');
70
+ expect((await handler({ spaceId: 'ACME-S1', action: 'retry' })).isError).toBe(true);
71
+ expect(textOf(await handler({ spaceId: 'ACME-S1', action: 'retry', runId: '11111111-0000-4000-8000-000000000000' }))).toContain('ran again: applied');
72
+ expect(post).toHaveBeenCalledWith('/spaces/ACME-S1/llm-wiki/ingest-runs/11111111-0000-4000-8000-000000000000/retry', {});
73
+ });
74
+ });
@@ -9,7 +9,7 @@ import { mcpInstanceToken, resolveProjectByKey, resolveTicketByKey } from './sha
9
9
  const ACTIONS = ['list', 'health', 'connect', 'update', 'rotate_secret', 'disconnect', 'triage_lane', 'verdict'];
10
10
  export const sentryToolConfig = {
11
11
  title: 'Sentry connector',
12
- description: 'Sentry connector (ORB-2123): list, health, connect (orgSlug, projectSlug, clientSecret), update (triageMode comment|fix_branch), rotate_secret, disconnect, triage_lane (laneId, empty unbinds), verdict (ticketKey, verdict known_fixed|regression|new, summary). Secrets never come back; docs/sentry-intake.md.',
12
+ description: 'Sentry connector (ORB-2123). Actions: list, health, connect (orgSlug, projectSlug, clientSecret), update (triageMode comment|fix_branch), rotate_secret, disconnect, triage_lane (laneId, empty unbinds), verdict (ticketKey, verdict known_fixed|regression|new, summary). Secrets never come back; docs/sentry-intake.md.',
13
13
  inputSchema: z.object({
14
14
  action: z.enum(ACTIONS),
15
15
  projectKey: z.string().min(1),
@@ -21,7 +21,8 @@ import { agentHeadText } from '../agent-head.js';
21
21
  export const sessionStartToolConfig = {
22
22
  title: 'Load the rules you must follow + re-orient',
23
23
  description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it as your FIRST action in a session and immediately AFTER any context compaction. Returns a one-line-per-rule INDEX of the binding working-rules plus their hash (or a compact "unchanged" ack on a repeat call within the same connection) - read the index, then call this tool again with `rulesOnly: true` to read the full text of the rules whenever you do not already hold that exact hash, and expand before acting on any rule whose title touches what you are about to do. Also returns your in-progress tickets - each flagged LANDED, IDLE when it has a linked commit but has not moved for days, i.e. finished work you never handed to review - your running timer, and a warning if a project\'s git connection looks unhealthy (commit ingestion may be stalled). Pass `ticketKey` to also get a one-shot bundle for that ticket: project primer, the full ticket with dependencies + checklists, that project\'s git health, and any other agent sessions currently on it - replacing several separate calls. (Do NOT use orboto_list_agent_instructions to read the rules - that tool MANAGES/edits rule blocks for admins; this one is what you read to know how to work.) Registers this session (a heartbeat on its instance row) and is otherwise read-only. '
24
- + '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text (nothing else) and is never truncated - the cheapest way to read the rules the index listed. `projectId` adds that project\'s rules on top of the workspace + personal ones. `ticketKey` ("ACME-42") bundles that ticket\'s primer, full detail, dependencies, checklists, git health and other active agent sessions into the same response. `forceRules: true` returns the full rules text inline with the rest of the digest even when this connection already delivered them - use it whenever the rules are NOT in your context right now: after a compaction, a /clear, or a fresh agent taking over an existing connection. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) are your self-declared classification; rule blocks and per-tier rule text are targeted by them. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility; the returned session.ref is what peers pass as toSessionRef (ORB-2136).',
24
+ + '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text (nothing else) and is never truncated - the cheapest way to read the rules the index listed. `projectId` adds that project\'s rules on top of the workspace + personal ones. `ticketKey` ("ACME-42") bundles that ticket\'s primer, full detail, dependencies, checklists, git health and other active agent sessions into the same response. `forceRules: true` returns the full rules text inline with the rest of the digest even when this connection already delivered them - use it whenever the rules are NOT in your context right now: after a compaction, a /clear, or a fresh agent taking over an existing connection. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) are your self-declared classification; rule blocks and per-tier rule text are targeted by them. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility; the returned session.ref is what peers pass as toSessionRef (ORB-2136). '
25
+ + 'The digest also carries the Knowledge block (ORB-2225): the wiki index of your declared projects and of the Operations wiki, their latest log entries and open lint issues; ask the wiki with orboto_knowledge_ask before asking a person, file durable facts with orboto_knowledge_add.',
25
26
  inputSchema: z.object({
26
27
  projectId: z.string().uuid().optional().describe('Also load this project\'s rules.'),
27
28
  ticketKey: z.string().min(3).optional().describe('Ticket key ("ACME-42") - bundles that ticket\'s full context.'),
@@ -132,6 +133,30 @@ async function buildTicketBundle(client, ticketKey) {
132
133
  },
133
134
  };
134
135
  }
136
+ /** ORB-2225 - the Knowledge block stays small; the rest is one expand away. */
137
+ export const KNOWLEDGE_BLOCK_CHARS = 1500;
138
+ export function knowledgeLines(text) {
139
+ if (text.length <= KNOWLEDGE_BLOCK_CHARS)
140
+ return { lines: text.split('\n') };
141
+ const cut = text.slice(0, KNOWLEDGE_BLOCK_CHARS);
142
+ const kept = cut.slice(0, Math.max(cut.lastIndexOf('\n'), 0));
143
+ const handle = storePayload('orboto_session_start', {
144
+ structuredContent: { knowledge: text }, text,
145
+ omitted: [{ path: 'knowledge', kind: 'string', omittedChars: text.length - kept.length }],
146
+ });
147
+ return {
148
+ handle,
149
+ lines: [...kept.split('\n'), `(Knowledge block cut at ${kept.length} of ${text.length} characters: orboto_response_expand { handle: "${handle}", path: "knowledge" }, or GET /knowledge.)`],
150
+ };
151
+ }
152
+ function knowledgeSummary(index, handle) {
153
+ return {
154
+ source: index.source,
155
+ spaces: index.spaces.map((s) => ({ spaceKey: s.spaceKey ?? s.spaceName, projectKey: s.projectKey, indexDocKey: s.indexDoc?.key ?? null, pages: s.pages, sources: s.sources, openLintIssues: s.openLintIssues })),
156
+ missing: index.missing,
157
+ ...(handle ? { handle } : {}),
158
+ };
159
+ }
135
160
  /** ORB-2209 - a declared scope stays with the instance token; the agent declares it once per terminal. */
136
161
  export function identityLine(registration) {
137
162
  const count = registration.reconnects ?? 0;
@@ -185,7 +210,7 @@ export function makeSessionStartHandler(client, opts = {}) {
185
210
  if (input.scope === undefined && registration && !registration.scope && envProjectScope()) {
186
211
  registration = toRegistration(await declareEnvProjectScope(client, instanceToken)) ?? registration;
187
212
  }
188
- const [me, rules, assigned, timer, inboxRaw] = await Promise.all([
213
+ const [me, rules, assigned, timer, inboxRaw, knowledge] = await Promise.all([
189
214
  client.get('/users/me').catch(() => null),
190
215
  loadRequiredRules(client, rulesPath, rulesParams.get('knownRulesHash') ?? undefined),
191
216
  client.get('/users/me/assigned-tickets?statuses=IN_PROGRESS,IN_REVIEW&limit=20').catch(() => ({ items: [] })),
@@ -193,6 +218,9 @@ export function makeSessionStartHandler(client, opts = {}) {
193
218
  client.get('/v1/agent/messages?limit=10', { instanceToken })
194
219
  .then((r) => (Array.isArray(r?.messages) ? r.messages : []))
195
220
  .catch(() => []),
221
+ client.get('/knowledge', { instanceToken })
222
+ .then((r) => (typeof r?.text === 'string' && Array.isArray(r.spaces) && Array.isArray(r.missing) ? r : null))
223
+ .catch(() => null),
196
224
  ]);
197
225
  const pendingMessages = inboxRaw.map((m) => ({ id: m.id, fromUserId: m.fromUserId, from: m.from?.label ?? m.fromUserId, kind: m.kind, subject: m.subject, createdAt: m.createdAt }));
198
226
  const tickets = Array.isArray(assigned) ? assigned : (assigned?.items ?? []);
@@ -267,6 +295,9 @@ export function makeSessionStartHandler(client, opts = {}) {
267
295
  }
268
296
  lines.push('', '## Timer');
269
297
  lines.push(timer?.ticketId ? `Running on ${timer.ticketKey ?? timer.ticketId} since ${timer.startedAt ?? 'earlier'}.` : 'No timer running.');
298
+ const knowledgeBlock = knowledge?.text ? knowledgeLines(knowledge.text) : null;
299
+ if (knowledgeBlock)
300
+ lines.push('', ...knowledgeBlock.lines);
270
301
  lines.push('', '## This session');
271
302
  const scopeText = registration?.scope
272
303
  ? [registration.scope.role ? `role ${registration.scope.role}` : null, registration.scope.projectKeys?.length ? `projects ${registration.scope.projectKeys.join(', ')}` : null, registration.scope.ticketKeys?.length ? `tickets ${registration.scope.ticketKeys.join(', ')}` : null].filter(Boolean).join('; ')
@@ -315,6 +346,7 @@ export function makeSessionStartHandler(client, opts = {}) {
315
346
  .filter((p) => p.connections.length > 0),
316
347
  healthyCount: gitHealthWithConnections.reduce((n, p) => n + p.connections.filter((c) => c.healthy).length, 0),
317
348
  },
349
+ ...(knowledge ? { knowledge: knowledgeSummary(knowledge, knowledgeBlock?.handle) } : {}),
318
350
  ...(pendingMessages.length > 0 ? { pendingMessages } : {}),
319
351
  ...(bundle ? { ticketBundle: bundle.structured } : {}),
320
352
  },
@@ -54,7 +54,7 @@ export function makeWikiAskHandler(client) {
54
54
  }
55
55
  export const wikiLintToolConfig = {
56
56
  title: 'Run the LLM-Wiki lint pass on a space',
57
- description: 'Scan an LLM-Wiki space for inconsistencies (orphan pages, missing cross-references, stale pages, unprocessed sources, and - when AI is configured - contradictions and undocumented concepts). Returns the open issues, each with a suggested fix. Wraps POST /spaces/:id/llm-wiki/lint.',
57
+ description: 'Lint an LLM-Wiki space for inconsistencies. It finds orphan pages, missing cross-references, stale pages, unprocessed sources and, when AI is configured, contradictions and undocumented concepts. Returns the open issues, each with a suggested fix. Wraps POST /spaces/:id/llm-wiki/lint.',
58
58
  inputSchema: z.object({
59
59
  spaceId: z.string().uuid().describe('The LLM-Wiki space to lint.'),
60
60
  }).shape,
@@ -91,7 +91,7 @@ export function makeWikiPlanUpdateHandler(client) {
91
91
  }
92
92
  export const wikiApplyPlanToolConfig = {
93
93
  title: 'Apply a previously-planned wiki edit',
94
- description: 'Commit the page operations from a plan created by orboto_wiki_plan_update. Applies every op atomically (each page edit is snapshotted for rollback). Fails with 410 if the plan has expired (>15 min) or was already applied. Wraps POST /spaces/:id/docs/apply-plan.',
94
+ description: 'Commit the page operations from a plan created by orboto_wiki_plan_update. Applies every op atomically (each page edit is snapshotted for rollback), then rebuilds the index doc of the space and appends a line to its log doc. Fails with 410 if the plan has expired (>15 min) or was already applied. Wraps POST /spaces/:id/docs/apply-plan.',
95
95
  inputSchema: z.object({
96
96
  spaceId: z.string().uuid(),
97
97
  planId: z.string().uuid().describe('The planId returned by orboto_wiki_plan_update.'),
@@ -106,7 +106,7 @@ export function makeWikiApplyPlanHandler(client) {
106
106
  }
107
107
  export const wikiRecordToolConfig = {
108
108
  title: 'Record a wiki update in one step (plan + apply)',
109
- description: 'Convenience wrapper that plans an edit from your instruction and immediately applies it - use mid-task to capture a fact or update a page without the two-step review loop. Internally calls plan-update then apply-plan. For a reviewable change, use orboto_wiki_plan_update instead.',
109
+ description: 'Plan and apply a wiki edit in one step. Use it mid-task to capture a fact or update a page without the two-step review loop. Internally calls plan-update then apply-plan. For a reviewable change, use orboto_wiki_plan_update instead.',
110
110
  inputSchema: z.object({
111
111
  spaceId: z.string().uuid(),
112
112
  instruction: z.string().min(1).max(4000).describe('What to record, in plain language.'),
@@ -175,3 +175,28 @@ export function makeWikiFlagStaleHandler(client) {
175
175
  return text(res.staleFlagged ? 'Page flagged as possibly outdated.' : 'Stale flag cleared.', { staleFlagged: res.staleFlagged });
176
176
  };
177
177
  }
178
+ export const wikiRunsToolConfig = {
179
+ title: 'Wiki ingest runs',
180
+ description: 'List the ingest runs of a wiki space (status failed shows each error) or retry a failed run.',
181
+ inputSchema: z.object({
182
+ spaceId: z.string().min(1),
183
+ action: z.enum(['list', 'retry']).optional(),
184
+ runId: z.string().uuid().optional(),
185
+ status: z.enum(['pending_review', 'applied', 'failed', 'skipped']).optional(),
186
+ }).shape,
187
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
188
+ };
189
+ export function makeWikiRunsHandler(client) {
190
+ return async (input) => {
191
+ if (input.action === 'retry') {
192
+ if (!input.runId)
193
+ return { isError: true, content: [{ type: 'text', text: 'action retry needs the runId of a failed run (action list, status failed).' }] };
194
+ const res = await client.post(`/spaces/${encodeURIComponent(input.spaceId)}/llm-wiki/ingest-runs/${input.runId}/retry`, {});
195
+ return text(res.queued ? `Run ${res.runId} is queued for the ingest worker again.` : `Run ${res.runId} ran again: ${res.status}${res.error ? ` - ${res.error}` : ''}.`, res);
196
+ }
197
+ const qs = input.status ? `?status=${input.status}` : '';
198
+ const res = await client.get(`/spaces/${encodeURIComponent(input.spaceId)}/llm-wiki/ingest-runs${qs}`);
199
+ const lines = res.runs.map((r) => `- ${r.id} ${r.status} ${r.sourceKey ?? ''} "${r.sourceTitle}" ${r.createdAt}${r.error ? ` - ${r.error}` : ''}`);
200
+ return text(lines.length ? lines.join('\n') : 'No ingest runs.', res);
201
+ };
202
+ }
@@ -314,7 +314,7 @@ export function makeWorkSessionStartHandler(client) {
314
314
  }
315
315
  export const workSessionFinishToolConfig = {
316
316
  title: 'Finish a work session (books time, frees the lease)',
317
- description: 'End a work session: its timer is stopped and booked, the (ticket, role) lease is released for the next agent, and the evidence you pass (commit sha + which gates you ran) is recorded on the session. Idempotent - finishing an already-finished session succeeds and still absorbs late evidence, so a retrying harness never has to distinguish "already done" from "failed". This does NOT close the ticket; ticket status is a separate, deliberate decision (use orboto_close_ticket once the acceptance criteria are verified). Prefer orboto_work_finish (ORB-1612) for an `implementation` session that is actually done - it does this AND the ticket transition AND the completion note in one call.',
317
+ description: 'End a work session: timer booked, lease released, evidence recorded. The timer is stopped and booked, the (ticket, role) lease is released for the next agent, and the evidence you pass (commit sha + which gates you ran) is recorded on the session. Idempotent - finishing an already-finished session succeeds and still absorbs late evidence, so a retrying harness never has to distinguish "already done" from "failed". This does NOT close the ticket; ticket status is a separate, deliberate decision (use orboto_close_ticket once the acceptance criteria are verified). Prefer orboto_work_finish (ORB-1612) for an `implementation` session that is actually done - it does this AND the ticket transition AND the completion note in one call.',
318
318
  inputSchema: z.object({
319
319
  sessionId: z.string().uuid().describe('The id returned by orboto_work_session_start.'),
320
320
  outcome: z.enum(['finished', 'cancelled']).optional()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.207.0",
3
+ "version": "0.208.0",
4
4
  "description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
5
5
  "license": "MIT",
6
6
  "type": "module",